康心伴Logo
康心伴WellAlly
前端开发

构建符合 WCAG 标准的无障碍健康数据可视化

5 分钟阅读

构建符合 WCAG 标准的无障碍健康数据可视化

概述

健康数据可视化应该让所有用户都能访问,包括视力障碍、色盲或其他残疾的用户。本文将介绍如何按照 WCAG 2.1 标准构建无障碍的数据可视化组件。

WCAG 2.1 核心原则

WCAG 2.1 定义了四个核心原则,简称 POUR:

  • Perceivable(可感知):信息必须以用户能够感知的方式呈现
  • Operable(可操作):UI 组件必须可操作
  • Understandable(可理解):信息和操作必须可理解
  • Robust(健壮):内容必须足够健壮,可被各种用户代理(包括辅助技术)解释

颜色与对比度

对比度要求

WCAG AA 级别要求:

  • 普通文本:至少 4.5:1
  • 大文本(18pt+ 或 14pt 粗体+):至少 3:1
  • 图形对象:至少 3:1

无障碍调色板

code
// lib/colors.ts

/**
 * 无障碍健康数据可视化调色板
 * 所有颜色组合均符合 WCAG AA 标准(对比度 >= 4.5:1)
 */
export const ACCESSIBLE_COLORS = {
  // 主色调 - 与白色背景对比度 >= 4.5:1
  primary: {
    blue: '#0066CC',      // 对比度 7.6:1
    green: '#008000',     // 对比度 7.4:1
    orange: '#B86E00',    // 对比度 4.6:1
    red: '#C90000',       // 对比度 6.3:1
    purple: '#6B2E9E',    // 对比度 5.8:1
    teal: '#007870',      // 对比度 5.2:1
  },

  // 浅色调 - 与白色背景对比度 >= 3:1(用于大文本)
  light: {
    blue: '#5DA3D9',      // 对比度 3.1:1
    green: '#4DB87A',     // 对比度 3.0:1
    orange: '#E89400',    // 对比度 3.0:1
    red: '#E53935',       // 对比度 3.1:1
  },

  // 色盲友好调色板
  colorblindSafe: {
    blue: '#0072B2',      // 蓝色
    orange: '#D55E00',    // 橙色
    green: '#009E73',     // 绿色
    yellow: '#F0E442',    // 黄色
    darkBlue: '#56B4E9',  // 浅蓝色
    vermillion: '#CC79A7', // 猩红色
  },

  // 灰度
  grayscale: {
    black: '#000000',     // 对比度 21:1
    darkGray: '#333333',  // 对比度 12.6:1
    gray: '#666666',      // 对比度 7.0:1
    lightGray: '#999999', // 对比度 3.1:1
  },

  // 边框和分隔线
  borders: {
    light: '#E0E0E0',     // 与白色对比度 1.2:1(仅用于装饰)
    medium: '#BDBDBD',    // 与白色对比度 1.6:1
    dark: '#757575',      // 与白色对比度 3.9:1
  }
} as const;

/**
 * 对比度计算工具
 */
export function getContrastRatio(foreground: string, background: string): number {
  const getLuminance = (hex: string): number => {
    const rgb = hexToRgb(hex);
    const [r, g, b] = [rgb.r, rgb.g, rgb.b].map(val => {
      val = val / 255;
      return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
    });
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
  };

  const lum1 = getLuminance(foreground);
  const lum2 = getLuminance(background);
  const brightest = Math.max(lum1, lum2);
  const darkest = Math.min(lum1, lum2);
  return (brightest + 0.05) / (darkest + 0.05);
}

function hexToRgb(hex: string): { r: number; g: number; b: number } {
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  return result ? {
    r: parseInt(result[1], 16),
    g: parseInt(result[2], 16),
    b: parseInt(result[3], 16)
  } : { r: 0, g: 0, b: 0 };
}
Code collapsed

颜色不止一种方式传达信息

code
// components/charts/AccessibleBarChart.tsx

import React from 'react';
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { ACCESSIBLE_COLORS } from '@/lib/colors';

interface AccessibleBarChartProps {
  data: Array<{
    name: string;
    value: number;
    category: string;
  }>;
  title: string;
  description: string;
}

export const AccessibleBarChart: React.FC<AccessibleBarChartProps> = ({
  data,
  title,
  description
}) => {
  // 使用图案和颜色组合
  const patterns = {
    dots: 'url(#dots)',
    lines: 'url(#lines)',
    crosshatch: 'url(#crosshatch)'
  };

  return (
    <div role="region" aria-labelledby="chart-title" aria-describedby="chart-desc">
      {/* 屏幕阅读器专用标题 */}
      <h2 id="chart-title" className="sr-only">
        {title}
      </h2>
      <p id="chart-desc" className="sr-only">
        {description}
      </p>

      <ResponsiveContainer width="100%" height={300}>
        <BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
          <defs>
            {/* 图案定义 */}
            <pattern id="dots" x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse">
              <circle cx="5" cy="5" r="1" fill="#666" opacity="0.3" />
            </pattern>
            <pattern id="lines" x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse">
              <path d="M0,5 L10,5" stroke="#666" strokeWidth="1" opacity="0.3" />
            </pattern>
            <pattern id="crosshatch" x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse">
              <path d="M0,0 L10,10 M10,0 L0,10" stroke="#666" strokeWidth="1" opacity="0.3" />
            </pattern>
          </defs>

          <CartesianGrid strokeDasharray="3 3" stroke={ACCESSIBLE_COLORS.borders.light} />
          <XAxis
            dataKey="name"
            stroke={ACCESSIBLE_COLORS.grayscale.darkGray}
            tick={{ fill: ACCESSIBLE_COLORS.grayscale.black }}
          />
          <YAxis
            stroke={ACCESSIBLE_COLORS.grayscale.darkGray}
            tick={{ fill: ACCESSIBLE_COLORS.grayscale.black }}
          />
          <Tooltip
            contentStyle={{
              backgroundColor: '#FFFFFF',
              border: `1px solid ${ACCESSIBLE_COLORS.borders.medium}`,
              color: ACCESSIBLE_COLORS.grayscale.black
            }}
          />
          <Legend />

          {/* 使用颜色和图案的组合 */}
          <Bar
            dataKey="value"
            fill={ACCESSIBLE_COLORS.primary.blue}
            name="数值"
          >
            {data.map((entry, index) => (
              <rect
                key={`pattern-${index}`}
                fill={index % 3 === 0 ? patterns.dots : index % 3 === 1 ? patterns.lines : patterns.crosshatch}
              />
            ))}
          </Bar>
        </BarChart>
      </ResponsiveContainer>

      {/* 数据表格作为替代方案 */}
      <details className="mt-4">
        <summary className="cursor-pointer text-blue-600 hover:text-blue-800">
          查看数据表格
        </summary>
        <AccessibleDataTable data={data} />
      </details>
    </div>
  );
};

const AccessibleDataTable: React.FC<{ data: Array<{ name: string; value: number; category: string }> }> = ({ data }) => {
  return (
    <table className="w-full mt-4 border-collapse">
      <thead>
        <tr className="border-b">
          <th className="text-left p-2">名称</th>
          <th className="text-left p-2">类别</th>
          <th className="text-right p-2">数值</th>
        </tr>
      </thead>
      <tbody>
        {data.map((item, index) => (
          <tr key={index} className="border-b">
            <td className="p-2">{item.name}</td>
            <td className="p-2">{item.category}</td>
            <td className="p-2 text-right">{item.value}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
};
Code collapsed

键盘导航

焦点管理

code
// components/charts/KeyboardNavigableChart.tsx

import React, { useState, useRef, useCallback } from 'react';
import { useKeyboardNavigation } from '@/hooks/useKeyboardNavigation';

interface KeyboardNavigableChartProps {
  data: Array<{ label: string; value: number }>;
}

export const KeyboardNavigableChart: React.FC<KeyboardNavigableChartProps> = ({ data }) => {
  const [focusedIndex, setFocusedIndex] = useState(-1);
  const containerRef = useRef<HTMLDivElement>(null);

  const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
    switch (event.key) {
      case 'ArrowRight':
      case 'ArrowDown':
        event.preventDefault();
        setFocusedIndex(prev => Math.min(prev + 1, data.length - 1));
        break;
      case 'ArrowLeft':
      case 'ArrowUp':
        event.preventDefault();
        setFocusedIndex(prev => Math.max(prev - 1, 0));
        break;
      case 'Home':
        event.preventDefault();
        setFocusedIndex(0);
        break;
      case 'End':
        event.preventDefault();
        setFocusedIndex(data.length - 1);
        break;
      case 'Enter':
      case ' ':
        event.preventDefault();
        if (focusedIndex >= 0) {
          // 激活选中的数据点
          handleDataPointActivate(data[focusedIndex]);
        }
        break;
      case 'Escape':
        event.preventDefault();
        setFocusedIndex(-1);
        break;
    }
  }, [focusedIndex, data]);

  const handleDataPointActivate = (item: typeof data[0]) => {
    console.log('Activated:', item);
    // 处理激活逻辑
  };

  return (
    <div
      ref={containerRef}
      role="application"
      aria-label="交互式图表"
      tabIndex={0}
      onKeyDown={handleKeyDown}
      className="relative"
    >
      {/* 图表内容 */}
      <svg viewBox="0 0 400 200" className="w-full">
        {data.map((item, index) => (
          <g key={index}>
            {/* 数据条 */}
            <rect
              x={index * 50 + 20}
              y={200 - item.value * 2}
              width="30"
              height={item.value * 2}
              fill={
                focusedIndex === index
                  ? ACCESSIBLE_COLORS.primary.orange
                  : ACCESSIBLE_COLORS.primary.blue
              }
              aria-label={`${item.label}: ${item.value}`}
              tabIndex={focusedIndex === index ? 0 : -1}
              onFocus={() => setFocusedIndex(index)}
            />
            {/* 标签 */}
            <text
              x={index * 50 + 35}
              y={195}
              textAnchor="middle"
              fill={ACCESSIBLE_COLORS.grayscale.black}
              fontSize="12"
            >
              {item.label}
            </text>
            {/* 焦点指示器 */}
            {focusedIndex === index && (
              <rect
                x={index * 50 + 15}
                y={200 - item.value * 2 - 5}
                width="40"
                height={item.value * 2 + 25}
                fill="none"
                stroke={ACCESSIBLE_COLORS.primary.orange}
                strokeWidth="2"
                strokeDasharray="5,5"
              />
            )}
          </g>
        ))}
      </svg>

      {/* 焦点提示 */}
      {focusedIndex >= 0 && (
        <div
          className="absolute top-0 right-0 bg-gray-900 text-white p-3 rounded shadow-lg"
          role="status"
          aria-live="polite"
        >
          <p className="font-semibold">{data[focusedIndex].label}</p>
          <p className="text-2xl font-bold">{data[focusedIndex].value}</p>
          <p className="text-xs text-gray-400 mt-2">
            使用方向键导航,Enter 激活,Esc 取消
          </p>
        </div>
      )}
    </div>
  );
};
Code collapsed

屏幕阅读器支持

ARIA 标签和实时更新

code
// components/charts/ScreenReaderFriendlyChart.tsx

import React, { useState } from 'react';

interface ScreenReaderFriendlyChartProps {
  title: string;
  data: Array<{
    label: string;
    value: number;
    change?: number;
  }>;
}

export const ScreenReaderFriendlyChart: React.FC<ScreenReaderFriendlyChartProps> = ({
  title,
  data
}) => {
  const [hoveredItem, setHoveredItem] = useState<typeof data[0] | null>(null);

  // 计算统计摘要
  const summary = {
    total: data.reduce((sum, item) => sum + item.value, 0),
    average: data.reduce((sum, item) => sum + item.value, 0) / data.length,
    max: Math.max(...data.map(item => item.value)),
    min: Math.min(...data.map(item => item.value))
  };

  return (
    <div
      role="region"
      aria-labelledby="chart-title"
      aria-describedby="chart-summary"
    >
      {/* 可见标题 */}
      <h2 id="chart-title" className="text-xl font-bold mb-4">
        {title}
      </h2>

      {/* 屏幕阅读器摘要 */}
      <div id="chart-summary" className="sr-only">
        {title}。包含 {data.length} 个数据点。
        总计 {summary.total.toFixed(0)},平均值 {summary.average.toFixed(1)}。
        最高 {summary.max},最低 {summary.min}。
        {data.map((item, index) =>
          `${item.label}为${item.value}。${item.change ? `与上期相比${item.change > 0 ? '增加' : '减少'}${Math.abs(item.change)}。` : ''}`
        ).join(' ')}
      </div>

      {/* 图表容器 */}
      <div
        className="relative"
        role="img"
        aria-label={`${title}图表`}
      >
        <svg viewBox="0 0 400 200" className="w-full">
          {data.map((item, index) => (
            <g key={index}>
              <circle
                cx={(index + 1) * (400 / (data.length + 1))}
                cy={200 - (item.value / summary.max) * 150}
                r="8"
                fill={ACCESSIBLE_COLORS.primary.blue}
                onMouseEnter={() => setHoveredItem(item)}
                onMouseLeave={() => setHoveredItem(null)}
                onFocus={() => setHoveredItem(item)}
                onBlur={() => setHoveredItem(null)}
                tabIndex={0}
                role="graphics-symbol"
                aria-label={`${item.label}: ${item.value}`}
              />
            </g>
          ))}
        </svg>

        {/* 实时更新区域 */}
        <div
          role="status"
          aria-live="polite"
          aria-atomic="true"
          className="sr-only"
        >
          {hoveredItem && `${hoveredItem.label}: ${hoveredItem.value}`}
        </div>
      </div>

      {/* 数据表格 */}
      <div className="mt-6">
        <h3 className="font-semibold mb-2">详细数据</h3>
        <table className="w-full">
          <thead>
            <tr className="border-b">
              <th className="text-left p-2">项目</th>
              <th className="text-right p-2">数值</th>
              <th className="text-right p-2">变化</th>
            </tr>
          </thead>
          <tbody>
            {data.map((item, index) => (
              <tr key={index} className="border-b">
                <td className="p-2">{item.label}</td>
                <td className="p-2 text-right">{item.value}</td>
                <td className="p-2 text-right">
                  {item.change !== undefined && (
                    <span className={
                      item.change >= 0 ? 'text-green-600' : 'text-red-600'
                    }>
                      {item.change >= 0 ? '+' : ''}{item.change}
                    </span>
                  )}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
};
Code collapsed

缩放与响应式设计

code
// components/charts/ResponsiveAccessibleChart.tsx

import React, { useState, useEffect } from 'react';

interface ResponsiveAccessibleChartProps {
  data: Array<{ label: string; value: number }>;
  title: string;
}

export const ResponsiveAccessibleChart: React.FC<ResponsiveAccessibleChartProps> = ({
  data,
  title
}) => {
  const [fontSize, setFontSize] = useState(14);
  const [containerWidth, setContainerWidth] = useState(0);
  const containerRef = useRef<HTMLDivElement>(null);

  // 响应式字体大小
  useEffect(() => {
    const updateSize = () => {
      if (containerRef.current) {
        const width = containerRef.current.offsetWidth;
        setContainerWidth(width);
        // 根据容器宽度调整字体大小
        setFontSize(width < 600 ? 12 : 14);
      }
    };

    updateSize();
    window.addEventListener('resize', updateSize);
    return () => window.removeEventListener('resize', updateSize);
  }, []);

  // 用户控制的缩放
  const [zoomLevel, setZoomLevel] = useState(1);

  return (
    <div ref={containerRef} className="w-full">
      {/* 缩放控制 */}
      <div className="flex items-center gap-2 mb-4" role="group" aria-label="缩放控制">
        <button
          onClick={() => setZoomLevel(Math.max(0.5, zoomLevel - 0.1))}
          aria-label="缩小"
          className="px-3 py-1 border rounded"
        >
          缩小
        </button>
        <span aria-live="polite" className="min-w-[3rem] text-center">
          {Math.round(zoomLevel * 100)}%
        </span>
        <button
          onClick={() => setZoomLevel(Math.min(2, zoomLevel + 0.1))}
          aria-label="放大"
          className="px-3 py-1 border rounded"
        >
          放大
        </button>
        <button
          onClick={() => setZoomLevel(1)}
          aria-label="重置缩放"
          className="px-3 py-1 border rounded"
        >
          重置
        </button>
      </div>

      {/* 图表 */}
      <div
        style={{
          transform: `scale(${zoomLevel})`,
          transformOrigin: 'top left',
          fontSize: `${fontSize * zoomLevel}px`
        }}
      >
        <svg
          viewBox="0 0 400 200"
          className="w-full"
          role="img"
          aria-label={title}
        >
          {/* 图表内容 */}
          {data.map((item, index) => (
            <text
              key={index}
              x={index * 50 + 35}
              y={190}
              textAnchor="middle"
              fontSize={fontSize}
              fill={ACCESSIBLE_COLORS.grayscale.black}
            >
              {item.label}
            </text>
          ))}
        </svg>
      </div>
    </div>
  );
};
Code collapsed

无障碍测试清单

code
// lib/accessibility-checklist.ts

export const ACCESSIBILITY_CHECKLIST = {
  colors: {
    items: [
      '所有文本与背景对比度至少 4.5:1',
      '大文本(18pt+)对比度至少 3:1',
      '不单独使用颜色传达信息',
      '支持色盲模式的颜色方案',
      '提供高对比度模式'
    ]
  },
  keyboard: {
    items: [
      '所有交互元素可通过键盘访问',
      '焦点指示器清晰可见',
      '支持标准快捷键(方向键、Enter、Esc)',
      '焦点顺序符合逻辑',
      '没有键盘陷阱'
    ]
  },
  screenReader: {
    items: [
      '提供有意义的 aria-label',
      '图表有文本摘要',
      '数据变化有 aria-live 通知',
      '提供数据表格替代方案',
      '使用语义化 HTML'
    ]
  },
  responsive: {
    items: [
      '支持 200% 缩放',
      '移动设备可用',
      '横竖屏切换正常',
      '文本可调整大小',
      '触摸目标足够大(44x44px)'
    ]
  }
};

// 无障碍测试组件
export const AccessibilityTestPanel: React.FC = () => {
  return (
    <div className="fixed bottom-4 right-4 bg-white rounded-lg shadow-xl p-4 max-w-sm">
      <h3 className="font-bold mb-2">无障碍测试面板</h3>
      {Object.entries(ACCESSIBILITY_CHECKLIST).map(([category, { items }]) => (
        <details key={category} className="mb-2">
          <summary className="cursor-pointer font-semibold capitalize">
            {category}
          </summary>
          <ul className="ml-4 mt-2 space-y-1">
            {items.map((item, index) => (
              <li key={index} className="flex items-start gap-2">
                <input type="checkbox" className="mt-1" />
                <span className="text-sm">{item}</span>
              </li>
            ))}
          </ul>
        </details>
      ))}
    </div>
  );
};
Code collapsed

参考资料

#

文章标签

可访问性
WCAG
数据可视化
健康科技
UI/UX

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅