嗯……禁果甜甜圈。”
– 荷马·辛普森
我最近需要为工作中的一个报表仪表盘创建一个甜甜圈图。我得到的线框图看起来像这样

我的图表有一些基本要求。它需要
- 根据任意一组值动态计算其片段
- 具有标签
- 在所有屏幕尺寸和设备上良好缩放
- 与回溯到 Internet Explorer 11 的所有浏览器兼容
- 可访问
- 在我的工作中的 Vue.js 前端中可重复使用
我还希望能够在需要时对其进行动画处理。所有这些听起来都像是 SVG 的工作。
SVG 本身即可访问(W3C 有一个关于此的完整部分),并且可以通过其他输入使其更易于访问。并且,由于它们由数据驱动,因此它们是动态可视化的完美候选者。
关于此主题的文章有很多,包括 Chris 的两篇(这里和这里)以及 Burke Holland 的一篇最新文章。我没有在这个项目中使用 D3,因为应用程序不需要该库的开销。
我为我的项目创建了图表作为 Vue 组件,但您也可以轻松地使用原生 JavaScript、HTML 和 CSS 来完成此操作。
这是最终产品
轮子圆圈
重新发明像任何自尊的开发者一样,我做的第一件事就是谷歌搜索,看看是否有人已经做过了。然后,像同一个开发者一样,我放弃了预建的解决方案,转而使用我自己的解决方案。
“SVG 甜甜圈图”的热门搜索结果是这篇文章,其中介绍了如何使用stroke-dasharray
和stroke-dashoffset
绘制多个重叠的圆圈并创建单个分段圆圈的错觉(稍后详细介绍)。
我非常喜欢叠加的概念,但发现重新计算stroke-dasharray
和stroke-dashoffset
值很令人困惑。为什么不设置一个固定的stroke-dasharrary
值,然后使用transform
旋转每个圆圈呢?我还需要为每个片段添加标签,这在教程中没有介绍。
绘制线条
在我们创建动态甜甜圈图之前,我们首先需要了解 SVG 线条绘制的工作原理。如果您还没有阅读 Jake Archibald 优秀的SVG 中的动画线条绘制。Chris 也有一个很好的概述。
这些文章提供了您需要的大部分上下文,但简而言之,SVG 有两个表示属性:stroke-dasharray
和stroke-dashoffset
。
stroke-dasharray
定义了一个用于绘制形状轮廓的虚线和间隙数组。它可以取零个、一个或两个值。第一个值定义虚线长度;第二个值定义间隙长度。
stroke-dashoffset
另一方面,定义了虚线和间隙集的起始位置。如果stroke-dasharray
和stroke-dashoffset
值等于线的长度,则整条线都可见,因为我们告诉偏移量(虚线数组的起始位置)从线的末尾开始。如果stroke-dasharray
是线的长度,但stroke-dashoffset
为 0,则该线不可见,因为我们正在将虚线的渲染部分偏移其整个长度。
Chris 的示例很好地演示了这一点
我们将如何构建图表
要创建甜甜圈图的片段,我们将为每个片段创建一个单独的圆圈,将这些圆圈彼此叠加,然后使用stroke
、stroke-dasharray
和stroke-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
值,所以每个圆圈的笔划都围绕整个圆圈。让我们通过创建片段来解决这个问题。
创建片段
要获取每个圆圈片段,我们需要
- 计算每个数据值在我们传入的总数据值中的百分比
- 将此百分比乘以周长以获取可见笔划的长度
- 从周长中减去此长度以获取
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
是角度,a
和b
是 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
,您可能也需要调整位置。查看dx和dy以了解这一点。
改进的文本元素
<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技巧
很棒的教程,但我建议任何考虑在他们的 UI 中使用饼图或甜甜圈图的人阅读 Stephen Few 的“将派留给甜点 - 感知边缘”(PDF),它对它们的不足之处进行了详尽的阐述。
很酷!很棒的文章。
精彩的文章,非常详细和解释清楚。
即使我没有使用该框架,我也能够毫无问题地将其适配到我的应用程序中。
非常感谢您分享。