随心而记
1607 字
8 分钟
使用 antv/g2 绘制‘旭日图’
2024-04-15
发布时间
分类

为什么标题中的旭日图打双引号呢,是因为虽然它的样式长得像是旭日图,但是数据结构却和旭日图所需要的相差远。

在官方示例中,旭日图的数据结构是父节点不设置值的大小,而是由它的所有子节点累加确认的。因此,旭日图是**父节点一定对应一个或者多个子节点,且这些子节点不能超过内圈父节点的范围。**而现在产品想要的效果是父节点和子节点没有关联,只是父节点的值需要等于子节点的累加值(这里的父节点由两部分组成),这样的话子节点一定会跨过这两个父节点的临界,产品原型如下

Untitled

径向堆叠条形图#

因此,旭日图并不满足这个需求,我和产品反映了这个问题,他说让我使用径向堆叠条形图试试,在我一番调试下,发现这个虽然在外观上能实现这样的图形,但是在其他的很多方面都是不符合需求的。

使用径向堆叠图实现的效果

使用径向堆叠图实现的效果

由于径向堆叠图的数据结构通常是同一组类型在不同状态(时期)下的数据,而这个需求的数据结构是不同类型在在不同状态(时期)的数据,所以导致它有诸多问题

主要就是 tooltip 展示问题

内层展示还算是正常

Untitled

外层展示就完全不对应了

Untitled

我猜测这是由于径向堆叠图的数据结构设计是想要展示内外侧是相同的,但是这里我给出的数据确实不同的,而它展示的逻辑可能是,由 chart.data.transform 的 fields 数组中定义所有字段依次展示。

这是会导致 tooltip 不对应的问题。

const data = [
  {
    State: '',
    天猫: 25,
    天猫_购买: 15,
    京东: 20,
    京东_购买: 10,
    抖音: 10,
    抖音_购买: 5,
    微信: 10,
    微信_购买: 5,
  },
  {
    State: ' ',
    百利: 5,
    百利_购买: 5,
    尊尼获加: 5,
    尊尼获加_购买: 5,
    帝亚吉欧: 10,
    帝亚吉欧_购买: 10,
  },
];

// chart.data 如下
{
  value: data,
  transform: [
	  {
		  type: 'fold',
		  fields: ['天猫', '天猫_购买', '京东', '京东_购买', '抖音', '抖音_购买', '微信', '微信_购买', '百利', '百利_购买', '尊尼获加', '尊尼获加_购买', '帝亚吉欧', '帝亚吉欧_购买'],
		  key: 'key',
		  value: '会员总数',
		  retains: ['State'],
		},
	],
}

最终解决方案#

最后我想到 echart 中之前画过嵌套饼图,但是在 antv/g2(v5) 版本的图标示例中并没有嵌套饼图的例子,查看文档发现,实现双视图需要使用到复合实现,我们这里的需求是视图的层叠,因此使用的是空间复合

按照官方示例很容易就绘制出了嵌套饼图,但是这里会出现图形大小层叠的问题,导致外层图形的 tooltip 无法正常使用。这是因为每个圆环实际上是在一个正方形的区域内绘制的,这个区域的大小是由圆环的直径决定的。如果你创建了两个嵌套的圆环,后一个圆环可能会在事件处理上覆盖前一个圆环,导致前一个圆环的tooltip失效。具体表现图如下

Untitled

而我在文档中并没有发现能够改变这一规则的属性,因此我想如果改变内圈直径,使其大小不遮挡外层圆环,这样的话就不会影响外层 tooltip 触发。最后我使用 attr 修改圆环的宽高,然后再使用coordinate.outerRadiuscoordinate.innerRadius 修改圆环视觉上的大小,最终实现代码如下

const data1 = [
  { item: '天猫', bgColor: '#e8b038', count: 20, percent: 0.2 },
  { item: '天猫-purchase', bgColor: '#e8b038', type: 'purchase', count: 10, percent: 0.1 },
  { item: '京东', bgColor: '#528df3', count: 10, percent: 0.1 },
  { item: '京东-purchase', bgColor: '#528df3', type: 'purchase', count: 10, percent: 0.1 },
  { item: '抖音', bgColor: '#ff3d72', count: 20, percent: 0.2 },
  { item: '抖音-purchase', bgColor: '#ff3d72', type: 'purchase', count: 10, percent: 0.1 },
  { item: '微信', bgColor: '#00a54e', count: 15, percent: 0.15 },
  { item: '微信-purchase', bgColor: '#00a54e', type: 'purchase', count: 5, percent: 0.05 },
];

const data2 = [
  { item: '帝亚吉欧', bgColor: '#9ecbfb', count: 10, percent: 0.1 },
  { item: '帝亚吉欧-purchase', type: 'purchase', bgColor: '#9ecbfb', count: 5, percent: 0.05 },
  { item: '尊尼获加', bgColor: '#add7fc', count: 7, percent: 0.07 },
  { item: '尊尼获加-purchase', type: 'purchase', bgColor: '#add7fc', count: 3, percent: 0.03 },
  { item: '百利', bgColor: '#bfe2fd', count: 3, percent: 0.03 },
  { item: '百利-purchase', type: 'purchase', bgColor: '#bfe2fd', count: 2, percent: 0.02 },
  { item: 'not-show', bgColor: 'transparent', count: 70, percent: 0.7 },
];

// 创建图表实例
const chart = new Chart({
  container: 'channel-members',
  height: 311,
});
const layer = chart.spaceLayer();

layer
  .interval()
  .data(data2)
  .coordinate({ type: 'theta', outerRadius: 1.1, innerRadius: 0.82 })
  .transform({ type: 'stackY' })
  .attr('x', 640 / 2 - 150)
  .attr('y', 311 / 2 - 150)
  .attr('width', 300)
  .attr('height', 300)
  .encode('y', 'percent')
  .style('fill', (d: any) => {
    const result = {
      image: lines({
        backgroundColor: d.bgColor,
        backgroundOpacity: 0.6,
        stroke: d.bgColor,
        lineWidth: 1,
        spacing: 0,
      }),
      repetition: 'repeat',
      transform: 'rotate(30deg)',
    }
    if (d.type === 'purchase') {
      result.image = lines({
        backgroundColor: 'transparent',
        backgroundOpacity: 0.6,
        stroke: d.bgColor,
        lineWidth: 1,
        spacing: 10,
      });
    }
    return result;
  })
  .interaction('tooltip', { disableNative: true })
  .tooltip((data) => {
    if (data.item === '') {
      return '';
    }
    return {
      name: data.item,
      value: `${data.percent * 100}%`,
    }
  });

layer
  .interval()
  .data(data1)
  .coordinate({ type: 'theta', outerRadius: 3.2, innerRadius: 1.6 })
  .transform({ type: 'stackY' })
  .attr('x', 640 / 2 - 50)
  .attr('y', 311 / 2 - 50)
  .attr('width', 100)
  .attr('height', 100)
  .encode('y', 'percent')
  .style('fill', (d: any) => {
    const result = {
      image: lines({
        backgroundColor: d.bgColor,
        backgroundOpacity: 0.6,
        stroke: d.bgColor,
        lineWidth: 1,
        spacing: 0,
      }),
      repetition: 'repeat',
      transform: 'rotate(30deg)',
    }
    if (d.type === 'purchase') {
      result.image = lines({
        backgroundColor: 'transparent',
        backgroundOpacity: 0.6,
        stroke: d.bgColor,
        lineWidth: 1,
        spacing: 10,
      });
    }
    return result;
  })
  .tooltip((data) => ({
    name: data.item,
    value: `${data.percent * 100}%`,
  }));

chart.on(`element:${ChartEvent.POINTER_OVER}`, ({ data }) => {
  if (data.data.item === 'not-show') {
    return;
  }
  chart.emit('tooltip:show', { data })
});
chart.on(`element:${ChartEvent.POINTER_MOVE}`, ({ data }) => {
  if (data.data.item === 'not-show') {
    return;
  }
  chart.emit('tooltip:show', { data })
});
chart.on(`plot:${ChartEvent.POINTER_OVER}`, () => chart.emit('tooltip:hide'));

chart.render();

由于只有天猫拥有子店铺,所以外层圆环是不完整的,这里使用 data.item === ‘not-show’ 控制是否显示,实现是通过style.fill 填充透明色。如果不设置这一项的话,剩下的项会填充整个圆环,不符合需求。这样设置后将鼠标放置到外层空白区域仍然会触发 tooltip,这里我的解决方案是使用自定义交互事件,判断是否 data.item === ‘not-show’ 为 true,如果不是则触发 tooltip 。

最终实现效果如下

Untitled

Untitled

Untitled

使用 antv/g2 绘制‘旭日图’
https://www.promises.top/posts/drawing-sunburst-charts-with-antv-g2/
作者
发布于
2024-04-15
许可协议
CC BY-NC-SA 4.0