使用 Vue 和 SVG 创建甜甜圈图

Avatar of Salomone Baquis
Salomone Baquis 发布

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 $200 免费额度!

嗯……禁果甜甜圈。”

– 荷马·辛普森

我最近需要为工作中的一个报表仪表盘创建一个甜甜圈图。我得到的线框图看起来像这样

我的图表有一些基本要求。它需要

  • 根据任意一组值动态计算其片段
  • 具有标签
  • 在所有屏幕尺寸和设备上良好缩放
  • 与回溯到 Internet Explorer 11 的所有浏览器兼容
  • 可访问
  • 在我的工作中的 Vue.js 前端中可重复使用

我还希望能够在需要时对其进行动画处理。所有这些听起来都像是 SVG 的工作。

SVG 本身即可访问(W3C 有一个关于此的完整部分),并且可以通过其他输入使其更易于访问。并且,由于它们由数据驱动,因此它们是动态可视化的完美候选者。

关于此主题的文章有很多,包括 Chris 的两篇(这里这里)以及 Burke Holland 的一篇最新文章我没有在这个项目中使用 D3,因为应用程序不需要该库的开销。

我为我的项目创建了图表作为 Vue 组件,但您也可以轻松地使用原生 JavaScript、HTML 和 CSS 来完成此操作。

这是最终产品

重新发明轮子圆圈

像任何自尊的开发者一样,我做的第一件事就是谷歌搜索,看看是否有人已经做过了。然后,像同一个开发者一样,我放弃了预建的解决方案,转而使用我自己的解决方案。

“SVG 甜甜圈图”的热门搜索结果是这篇文章,其中介绍了如何使用stroke-dasharraystroke-dashoffset绘制多个重叠的圆圈并创建单个分段圆圈的错觉(稍后详细介绍)。

我非常喜欢叠加的概念,但发现重新计算stroke-dasharraystroke-dashoffset值很令人困惑。为什么不设置一个固定的stroke-dasharrary值,然后使用transform旋转每个圆圈呢?我还需要为每个片段添加标签,这在教程中没有介绍。

绘制线条

在我们创建动态甜甜圈图之前,我们首先需要了解 SVG 线条绘制的工作原理。如果您还没有阅读 Jake Archibald 优秀的SVG 中的动画线条绘制。Chris 也有一个很好的概述

这些文章提供了您需要的大部分上下文,但简而言之,SVG 有两个表示属性stroke-dasharraystroke-dashoffset

stroke-dasharray定义了一个用于绘制形状轮廓的虚线和间隙数组。它可以取零个、一个或两个值。第一个值定义虚线长度;第二个值定义间隙长度。

stroke-dashoffset另一方面,定义了虚线和间隙集的起始位置。如果stroke-dasharraystroke-dashoffset值等于线的长度,则整条线都可见,因为我们告诉偏移量(虚线数组的起始位置)从线的末尾开始。如果stroke-dasharray是线的长度,但stroke-dashoffset为 0,则该线不可见,因为我们正在将虚线的渲染部分偏移其整个长度。

Chris 的示例很好地演示了这一点

我们将如何构建图表

要创建甜甜圈图的片段,我们将为每个片段创建一个单独的圆圈,将这些圆圈彼此叠加,然后使用strokestroke-dasharraystroke-dashoffset仅显示每个圆圈的一部分笔划。然后,我们将每个可见部分旋转到正确的位置,从而创建单个形状的错觉。在执行此操作时,我们还将计算文本标签的坐标。

这是一个演示这些旋转和叠加的示例

基本设置

让我们从设置结构开始。出于演示目的,我使用的是x-template,但建议您为生产环境创建单个文件组件。

<div id="app">
  <donut-chart></donut-chart>
</div>
<script type="text/x-template" id="donutTemplate">
  <svg height="160" width="160" viewBox="0 0 160 160">
    <g v-for="(value, index) in initialValues">
      <circle :cx="cx" :cy="cy" :r="radius" fill="transparent" :stroke="colors[index]" :stroke-width="strokeWidth" ></circle>
      <text></text>
    </g>
  </svg>
</script>
Vue.component('donutChart', {
  template: '#donutTemplate',
  props: ["initialValues"],
  data() {
    return {
      chartData: [],
      colors: ["#6495ED", "goldenrod", "#cd5c5c", "thistle", "lightgray"],
      cx: 80,
      cy: 80,                      
      radius: 60,
      sortedValues: [],
      strokeWidth: 30,    
    }
  }  
})
new Vue({
  el: "#app",
  data() {
    return {
      values: [230, 308, 520, 130, 200]
    }
  },
});

有了这个,我们

  • 创建我们的 Vue 实例和我们的甜甜圈图组件,然后告诉我们的甜甜圈组件期望一些值(我们的数据集)作为 props
  • 建立我们的基本 SVG 形状:用于片段的和用于标签的,并定义基本尺寸、笔划宽度和颜色
  • 将这些形状包装在元素中,该元素将它们组合在一起
  • <g>元素添加一个v-for循环,我们将使用它来迭代组件接收的每个值
  • 创建一个空的sortedValues数组,我们将使用它来保存我们数据的排序版本
  • 创建一个空的chartData数组,它将包含我们的主要定位数据

圆圈长度

我们的stroke-dasharray应该是整个圆圈的长度,这为我们提供了一个简单的基线数字,我们可以用它来计算每个stroke-dashoffset值。回想一下,圆的长度是它的周长,周长的公式是 2πr(你还记得吗?)。

我们可以在组件中将其设为计算属性。

computed: {
  circumference() {
    return 2 * Math.PI * this.radius
  }
}

……并将该值绑定到我们的模板标记。

<svg height="160" width="160" viewBox="0 0 160 160">
  <g v-for="(value, index) in initialValues">
    <circle :cx="cx" :cy="cy" :r="radius" fill="transparent" :stroke="colors[index]" :stroke-width="strokeWidth" :stroke-dasharray="circumference"></circle>
    <text></text>
  </g>
</svg>

在初始线框图中,我们看到片段从最大到最小。我们可以创建一个另一个计算属性来对它们进行排序。我们将排序后的版本存储在sortedValues数组中。

sortInitialValues() {
  return this.sortedValues = this.initialValues.sort((a,b) => b-a)
}

最后,为了使这些排序后的值在图表渲染之前可用于 Vue,我们需要从mounted()生命周期钩子中引用此计算属性。

mounted() {
  this.sortInitialValues                
}

现在,我们的图表看起来像这样

没有片段。只是一个纯色的甜甜圈。与 HTML 一样,SVG 元素按其在标记中出现的顺序渲染。显示的颜色是 SVG 中最后一个圆圈的笔划颜色。因为我们还没有添加任何stroke-dashoffset值,所以每个圆圈的笔划都围绕整个圆圈。让我们通过创建片段来解决这个问题。

创建片段

要获取每个圆圈片段,我们需要

  1. 计算每个数据值在我们传入的总数据值中的百分比
  2. 将此百分比乘以周长以获取可见笔划的长度
  3. 从周长中减去此长度以获取stroke-offset

这听起来比实际情况复杂。让我们从一些辅助函数开始。我们首先需要将数据值加总。我们可以使用计算属性来做到这一点。

dataTotal() {
  return this.sortedValues.reduce((acc, val) => acc + val)
},

要计算每个数据值的百分比,我们需要传入我们之前创建的v-for循环中的值,这意味着我们需要添加一个方法。

methods: {
  dataPercentage(dataVal) {
    return dataVal / this.dataTotal
  }
},

现在我们有足够的信息来计算我们的stroke-offset值,这将建立我们的圆圈片段。

同样,我们想要:(a) 将我们的数据百分比乘以圆周长以获取可见笔划的长度,以及 (b) 从周长中减去此长度以获取stroke-offset

这是获取我们的stroke-offset的方法

calculateStrokeDashOffset(dataVal, circumference) {
  const strokeDiff = this.dataPercentage(dataVal) * circumference
  return circumference - strokeDiff
},

……我们将其与HTML中的以下内容绑定到我们的圆圈中

:stroke-dashoffset="calculateStrokeDashOffset(value, circumference)"

瞧!我们应该得到类似这样的东西

旋转片段

现在是乐趣所在。所有片段都从 3 点钟方向开始,这是 SVG 圆圈的默认起始点。要将它们放在正确的位置,我们需要将每个片段旋转到其正确的位置。

我们可以通过找到每个片段在 360 度中的比例,然后将其偏移之前总的度数来做到这一点。

首先,让我们添加一个数据属性来跟踪偏移量

angleOffset: -90,

然后是我们的计算(这是一个计算属性)

calculateChartData() {
  this.sortedValues.forEach((dataVal, index) => {
    const data = {
      degrees: this.angleOffset,
    }
    this.chartData.push(data)
    this.angleOffset = this.dataPercentage(dataVal) * 360 + this.angleOffset
  })
},

每次循环都会创建一个新的对象,该对象具有“degrees”属性,将其推入我们之前创建的chartValues数组中,然后更新下一个循环的angleOffset

但是等等,-90 的值是怎么回事?

好吧,回顾我们最初的模型,第一个片段显示在 12 点钟位置,或者从起点开始的 -90 度。通过将我们的angleOffset设置为 -90,我们确保最大的甜甜圈片段从顶部开始。

要在 HTML 中旋转这些片段,我们将使用带有rotate函数的transform 展示属性。让我们创建一个另一个计算属性,以便我们可以返回一个格式良好的字符串。

returnCircleTransformValue(index) {
  return `rotate(${this.chartData[index].degrees}, ${this.cx}, ${this.cy})`
},

rotate函数接受三个参数:旋转角度以及角度围绕旋转的 x 和 y 坐标。如果我们不提供 cx 和 cy 坐标,那么我们的片段将围绕整个 SVG 坐标系旋转。

接下来,我们将它绑定到我们的圆形标记。

:transform="returnCircleTransformValue(index)"

并且,由于我们需要在图表渲染之前完成所有这些计算,因此我们将在挂载钩子中添加我们的calculateChartData计算属性

mounted() {
  this.sortInitialValues
  this.calculateChartData
}

最后,如果我们想要每个片段之间那个漂亮的间隙,我们可以从周长中减去 2,并将其用作新的stroke-dasharray

adjustedCircumference() {
  return this.circumference - 2
},
:stroke-dasharray="adjustedCircumference"

片段,宝贝!

标签

我们有了片段,但现在我们需要创建标签。这意味着我们需要放置我们的元素,并在圆圈上的不同点设置 x 和 y 坐标。你可能怀疑这需要数学运算。遗憾的是,你是对的。

幸运的是,这不是我们需要应用真实概念的那种数学;这更像是我们在 Google 上搜索公式,并且不要问太多问题。

根据互联网,计算圆上 x 和 y 点的公式是

x = r cos(t) + a
y = r sin(t) + b

…其中r是半径,t是角度,ab是 x 和 y 中心点偏移量。

我们已经拥有了大部分内容:我们知道我们的半径,我们知道如何计算我们的片段角度,并且我们知道我们的中心偏移值(cx 和 cy)。

不过,有一个问题:在这些公式中,t以*弧度*为单位。我们使用的是度数,这意味着我们需要进行一些转换。同样,快速搜索会显示一个公式

radians = degrees * (π / 180)

…我们可以在一个方法中表示它

degreesToRadians(angle) {
  return angle * (Math.PI / 180)
},

现在我们有足够的信息来计算我们的 x 和 y 文本坐标

calculateTextCoords(dataVal, angleOffset) {
  const angle = (this.dataPercentage(dataVal) * 360) / 2 + angleOffset
  const radians = this.degreesToRadians(angle)

  const textCoords = {
    x: (this.radius * Math.cos(radians) + this.cx),
    y: (this.radius * Math.sin(radians) + this.cy)
  }
  return textCoords
},

首先,我们通过将数据值的比率乘以 360 来计算片段的角度;但是,我们实际上想要其中的一半,因为我们的文本标签位于片段的中间而不是末尾。我们需要像创建片段时那样添加角度偏移。

我们的calculateTextCoords方法现在可以在calculateChartData计算属性中使用

calculateChartData() {
  this.sortedValues.forEach((dataVal, index) => {
    const { x, y } = this.calculateTextCoords(dataVal, this.angleOffset)        
    const data = {
      degrees: this.angleOffset,
      textX: x,
      textY: y
    }
    this.chartData.push(data)
    this.angleOffset = this.dataPercentage(dataVal) * 360 + this.angleOffset
  })
},

让我们也添加一个方法来返回标签字符串

percentageLabel(dataVal) {
  return `${Math.round(this.dataPercentage(dataVal) * 100)}%`
},

并且,在标记中

<text :x="chartData[index].textX" :y="chartData[index].textY">{{ percentageLabel(value) }}</text>

现在我们有了标签

糟糕,太偏离中心了。我们可以使用text-anchor展示属性来解决这个问题。根据您的字体和font-size,您可能也需要调整位置。查看dxdy以了解这一点。

改进的文本元素

<text text-anchor="middle" dy="3px" :x="chartData[index].textX" :y="chartData[index].textY">{{ percentageLabel(value) }}</text>

嗯,如果我们有很小的百分比,标签就会超出片段。让我们添加一个方法来检查这一点。

segmentBigEnough(dataVal) {
  return Math.round(this.dataPercentage(dataVal) * 100) > 5
}
<text v-if="segmentBigEnough(value)" text-anchor="middle" dy="3px" :x="chartData[index].textX" :y="chartData[index].textY">{{ percentageLabel(value) }}</text>

现在,我们只向大于 5% 的片段添加标签。

我们完成了!现在我们有一个可重用的甜甜圈图表组件,它可以接受任何一组值并创建片段。超级酷!

最终产品

后续步骤

现在我们已经构建了它,有很多方法可以修改或改进它。例如

  • 添加元素以增强可访问性,例如<title><desc>标签、aria-labels 和 aria 角色属性。
  • 使用 CSS 或像Greensock这样的库创建动画,以便在图表进入视图时创建醒目的效果。
  • 使用配色方案</code> 和 <code markup="tt"><desc></code> 标签、aria-labels 和 aria 角色属性。</li> <li>创建 <strong>动画</strong> 使用 CSS 或像 <a href="https://greensock.com/">Greensock</a> 这样的库,以便在图表进入视图时创建醒目的效果。</li> <li>使用 <strong>配色方案</strong>。</li> </ul> <p>我很想知道您对这种实现以及您在 SVG 图表方面遇到的其他体验有何看法。在评论中分享!</p> - CSS技巧

我很想知道您对这种实现以及您在 SVG 图表方面遇到的其他体验有何看法。在评论中分享!