发布于 2026-01-06 3 阅读
0

d3 甜甜圈图表的可访问性

d3 甜甜圈图表的可访问性

原文发布于www.a11ywithlindsey.com。如果您想观看屏幕阅读器如何解读这些内容的视频示例,请访问该网站!

嘿,朋友们!我上周刚从多伦多回来,在 a11yTO 大会上做了演讲,这是我为数不多想去演讲的会议之一!我的演讲主题是无障碍设计和 JavaScript。这次演讲的内容大致基于我之前写的博文《无障碍设计与 JavaScript——一段看似非同寻常的恋情》。所以,我现在很有动力写一篇关于 JavaScript 的博文。

我最喜欢的文章之一是关于无障碍条形图的文章。距离我写那篇文章已经过去大约……6个月了。看来系列文章就此搁置了,是吧?今天我将讲解如何使用d3制作无障碍环形图。我不会介绍所有花哨的功能和交互方式,而是专注于制作一个包含无障碍数据的环形图。

开始

首先声明,由于我最熟悉的是 d3,所以我的可视化图表是使用 d3 构建的。d3 渲染的是 SVG 格式,因此如果您需要,可以查看最终结果,了解 SVG 的渲染效果。

我还在学习D3,也算不上D3教学专家。这篇博文里我不会讲解D3的术语,所以如果你觉得D3很复杂,我建议你先查阅一些相关资料。如果条件允许,我推荐你去看看Shirley Wu在Frontend Masters上的课程。

首先,我们需要格式化数据集。我见过的大多数饼图都出现在金融应用中,所以我将使用我虚构的一位小企业主的支出示例。我不知道企业实际的支出是多少,但在这里数字并不重要。数据结构才是最重要的!我将创建一个包含两个属性的对象数组:标签和支出。

const data = [
  {
    label: 'Office Supplies',
    spending: 460,
  },
  {
    label: 'Transportation',
    spending: 95,
  },
  {
    label: 'Business Services',
    spending: 300,
  },
  {
    label: 'Restaurant',
    spending: 400,
  },
  {
    label: 'Entertainment',
    spending: 220,
  },
  {
    label: 'Travel',
    spending: 1000,
  },
  {
    label: 'Other',
    spending: 125.0,
  },
]

我的 HTML 代码中包含一个<svg>id 为 `<div>` 的标签donut-chart和一些初始的 d3 代码。我不会在这里一步一步地讲解 d3,因为这篇文章不是“如何学习 d3”,而是“如何让可视化内容更易于访问”。

我在这里所做的:

  1. 创建了一个饼图,用于d3.arc()生成 d 属性<path>d3.pie()正确格式化数据。
  2. 创建了一个工具提示,当鼠标悬停在弧线上时,会突出显示相应的数据。

为什么无法访问?

  1. 它会忽略使用键盘导航的用户。
  2. 它会忽略使用屏幕阅读器进行导航的用户。

方案一:使工具提示易于访问

这是我最不推荐的方案。不过,它能很好地教会我们如何进行无障碍测试,所以我很乐意尝试一下。

为了使工具提示易于访问,我们需要做以下几件事:

  1. 在所有弧形路径上添加一个箭头tabindex="0",以便我们可以按 Tab 键切换到该箭头。
  2. 为每个选定的路径添加一个aria-describedby具有唯一值的元素
  3. id在与值匹配的工具提示中添加一个标记aria-describedby。由于每个标记都id必须是唯一的,因此我们需要创建多个工具提示。
  4. 确保我们不仅添加鼠标事件,还要添加聚焦和失焦事件。

首先,我们来处理一下tabindex。下面的代码使路径可聚焦。

arcGroup
  .selectAll('.arc')
  .data(pie(data))
  .enter()
  .append('g')
  .attr('class', 'arc-group')
  .append('path')
  .attr('class', 'arc')
+ .attr('tabindex', 0)
  .attr('d', arc)
  .attr('fill', (d, i) => colors[i])
  .on('mousemove', () => {
    const { clientX, clientY } = d3.event
    d3.select('.tooltip').attr('transform', `translate(${clientX} ${clientY})`)
  })
  .on('mouseenter', d => {
    d3.select('.tooltip')
      .append('text')
      .text(`${d.data.label} - $${d.data.spending}`)
  })
  .on('mouseleave', () => d3.select('.tooltip text').remove())

现在我们可以用键盘来操作这些弧线了。

我打算做一点小小的调整,stroke当焦点落在弧线上时,给它加上一圈颜色。我还会用 CSS 移除它的轮廓(天哪!),然后在聚焦时添加描边颜色。

在 JavaScript 中:

arcGroup
  .selectAll('.arc')
  .data(pie(data))
  .enter()
  .append('g')
  .attr('class', 'arc-group')
  .append('path')
  .attr('class', 'arc')
  .attr('tabindex', 0)
  .attr('d', arc)
  .attr('fill', (d, i) => colors[i])
  .on('mousemove', () => {
    const { clientX, clientY } = d3.event
    d3.select('.tooltip').attr('transform', `translate(${clientX} ${clientY})`)
  })
  .on('mouseenter', d => {
    d3.select('.tooltip')
      .append('text')
      .text(`${d.data.label} - $${d.data.spending}`)
  })
+ .on('focus', function(d) {
+   d3.select(this)
+     .attr('stroke', 'black')
+     .attr('stroke-width', 2)
+ })
  .on('mouseleave', () => d3.select('.tooltip text').remove())

在 CSS 中

.arc:focus {
  outline: none;
}

接下来,我们需要aria-describedby给可聚焦的弧线添加一个属性。请记住,这意味着我们需要在工具提示本身上设置一个匹配的 ID。完成此操作后,如果屏幕阅读器用户将焦点放在某个弧线上,屏幕阅读器就会朗读这些工具提示中的内容。

每个弧线和每个工具提示都必须有一个唯一的标识符。这样,就不会让辅助技术感到困惑。为此,我不仅使用了数据,还使用了索引。

首先,我们来添加……aria-describedby

arcGroup
  .selectAll('.arc')
  .data(pie(data))
  .enter()
  .append('g')
  .attr('class', 'arc-group')
  .append('path')
  .attr('class', 'arc')
  .attr('tabindex', 0)
  .attr('d', arc)
  .attr('fill', (d, i) => colors[i])
+ .attr('aria-describedby', (d, i) => `tooltip-${i}`)
  .on('mousemove', () => {
    const { clientX, clientY } = d3.event
    d3.select('.tooltip').attr('transform', `translate(${clientX} ${clientY})`)
  })
  .on('mouseenter', d => {
    d3.select('.tooltip')
      .append('text')
      .text(`${d.data.label} - $${d.data.spending}`)
  })
  .on('focus', function(d) {
    d3.select(this)
      .attr('stroke', 'black')
      .attr('stroke-width', 2)
  })
  .on('mouseleave', () => d3.select('.tooltip text').remove())

让我们为每条数据添加一个工具提示,并添加一个id与该值匹配的元素aria-describedby

const tooltipGroup = svg.append('g').attr('class', 'tooltip')

tooltipGroup
  .selectAll('.tooltip-item')
  .data(data)
  .enter()
  .append('g')
+ .attr('id', (d, i) => `tooltip-${i}`)

现在我们要做的最后一件事是添加focus事件blur并更正d3.select()所有事件中的项目。

arcGroup
  .selectAll('.arc')
  .data(pie(data))
  .enter()
  .append('g')
  .attr('class', 'arc-group')
  .append('path')
  .attr('class', 'arc')
  .attr('tabindex', 0)
  .attr('d', arc)
  .attr('fill', (d, i) => colors[i])
  .attr('aria-describedby', (d, i) => `tooltip-${i}`)
  .on('mousemove', (d, i) => {
    const { clientX, clientY } = d3.event
-   d3.select('.tooltip')
+   d3.select(`#tooltip-${i}`)
      .attr('transform', `translate(${clientX} ${clientY})`)
  })
  .on('mouseenter', (d, i) => {
-   d3.select('.tooltip')
+   d3.select(`#tooltip-${i}`)
      .append('text')
      .text(`${d.data.label} - $${d.data.spending}`)
  })
  .on('focus', function(d, i) {
    d3.select(this)
      .attr('stroke', 'black')
      .attr('stroke-width', 2)

+   const { top, right, bottom, left } = d3.event
+     .target.getBoundingClientRect()
+
+   d3.select(`#tooltip-${i}`)
+     .append('text')
+     .text(`${d.data.label} - $${d.data.spending}`)
+     .attr('transform',
+       `translate(${(left + right) / 2} ${(top + bottom) / 2})`
+     )
  })
- .on('mouseleave', () => d3.select('.tooltip text').remove())
+ .on('mouseleave', (d, i) => d3.select(`#tooltip-${i} text`).remove())
+ .on('blur', function(d, i) {
+   d3.select(this).attr('stroke', null)
+   d3.select(`#tooltip-${i} text`).remove()
+ })

让我们回顾一下我们在这里所做的一些事情。

  • 我们已经修改了 d3 选择,使其更具体地针对ids 而不是类别。
  • 我们根据大致的“中间”位置来定位焦点处的工具提示。我使用以下方法计算了垂直和水平位置的平均值:.getBoundingClientRect()
  • 当事件模糊处理时,我已经移除了描边。

潜在问题:

  1. 根据数据集的大小,这意味着每个数据点都需要渲染一个工具提示。从长远来看,大量的独立工具提示可能会导致性能问题。
  2. 这种技术对 JavaScript 的依赖非常高,我担心性能问题。
  3. 当我们使用屏幕阅读器时,它会将所有标签的末尾都加上“图像”一词进行朗读。这与元素role的属性有关path

我们可以对此进行一些调整。

  1. 保持id工具提示本身的完整性
  2. 更新mouseenter目标aria-describedby以匹配id
  3. 移除“ aria-describedbyon”mouseleave
  4. focus更改和上的内容mouseenter

如果你愿意,可以 fork 我上面的 CodePen 代码,自己尝试一下。在网速较慢的情况下测试一下,也用屏幕阅读器测试一下。但我打算继续使用我更喜欢的版本,那就是创建图例。

方案二:创建单独的图例

我更倾向于这个方案。原因在于它简化了这些问题:

  • 为用户提供可视化呈现
  • 更少的绩效风险
  • 使用屏幕阅读器和键盘的人可以访问这些信息。

现在,让我们在原有代码的基础上添加图例。您可能已经注意到,我的代码中有两个变量:

const width = 571,
  chartWidth = 189

原因在于chartWidth,我们既有饼图的宽度,也有widthSVG本身的宽度。这样,我们就有了图例的空间。

首先,让我们来打造传奇。

我做的第一件事是为图例创建一个组,并使用transform属性定位它。这样定位图例可以更轻松地定位子项。

const legendGroup = svg
  .append('g')
  .attr('transform', `translate(${chartWidth} 0)`)
  .attr('class', 'legend-group')

上述代码将g元素放置在环形图旁边。接下来,我们创建图例项组,并根据它们的位置进行平移。

const legendItems = legendGroup
  .selectAll('g')
  .data(data)
  .enter()
  .append('g')
  .attr('transform', (d, i) => `translate(20 ${(i + 1) * 30})`)

利用数组的索引,我们通过乘法来确定每个元素的垂直位置(数学真棒!)。

接下来,我打算添加一个与对应弧线颜色相同的小方块。因为你知道,图例通常都会有图例说明。这并非出于辅助功能的目的。

legendItems
  .append('rect')
  .attr('y', -13)
  .attr('width', 15)
  .attr('height', 15)
  .attr('fill', (d, i) => colors[i])

添加形状对提高可访问性帮助不大,所以我们还是添加一些文字吧。

legendItems
  .append('text')
  .attr('x', 20)
  .text(d => `${d.label} - $${d.spending}`)

所以一切都搞定了,对吧?嗯,也不完全是。我们需要手动测试一下这个可视化效果。当然,这对视力正常用户和键盘用户来说都很好。但是它在屏幕阅读器上能正常工作吗?

我正在启用 VoiceOver 并在 Safari 中使用它(您应该始终在 Safari 中进行测试,因为它们都是 macOS 的原生功能)。

我通过手动测试发现,每个path元素(弧线)的角色都是图像,而且屏幕阅读器会朗读出来。因此,我打算role="presentation"在每个路径上都添加一个元素。

arcGroup
  .selectAll('.arc')
  .data(pie(data))
  .enter()
  .append('g')
  .attr('class', 'arc-group')
  .append('path')
+ .attr('role', 'presentation')
  .attr('class', 'arc')
  .attr('d', arc)
  .attr('fill', (d, i) => colors[i])
  .on('mousemove', () => {
    const { clientX, clientY } = d3.event
    d3.select('.tooltip').attr('transform', `translate(${clientX} ${clientY})`)
  })
  .on('mouseenter', d => {
    d3.select('.tooltip')
      .append('text')
      .text(`${d.data.label} - $${d.data.spending}`)
  })
  .on('mouseleave', () => d3.select('.tooltip text').remove())

添加演示角色会告诉屏幕阅读器:“这是用于演示的,屏幕阅读器可以忽略它。”

现在,它只显示图例文本的各个部分。我们可以做更多改进,例如添加图例标题并减少重复内容(朗读这些部分确实很重复)。但希望您已经学会了如何开始思考如何制作易于访问的数据可视化图表。

结论

在本文结尾,我想强调我常说的一点:手动测试是你的好朋友。我的做法未必是最佳方案,这只是一个可能的答案。关键在于,我总是使用屏幕阅读器、键盘和鼠标进行测试。在开始开发之前,我会考虑为残障用户提供哪些选项,以便于后续的适配。

保持联系!如果您喜欢这篇文章:

  • 请在推特上告诉我你的想法,也请把这篇文章分享给你的朋友们!此外,欢迎随时在推特上向我提出任何后续问题或想法。
  • 在Patreon上支持我吧!如果你喜欢我的作品,不妨考虑每月捐赠1美元。捐赠5美元或以上,你就可以参与未来博客文章的投票!我每月还会为所有赞助者举办一次“问我任何问题”的问答环节!
  • 抢先了解我发布的更多无障碍趣味内容!

祝好!祝你一周愉快!

文章来源:https://dev.to/lkopacz/accessibility-in-d3-donut-charts-2bgh