Jak vytvořit graf přístupný pro čtečku obrazovky, jako jsou jablka s D3.js

Původně zveřejněno na fossheim.io.

Poté, co jsem dříve psal o dostupnosti vizualizací dat Apple Health, jsem se cítil inspirován k opětovnému vytvoření jedné z nich pomocí D3.js. Některé základy jsem již probral ve formě sloupcového grafu, takže jsem se tentokrát rozhodl pro jiný typ grafu:kroužky aktivity.

Než začneme

I když budeme společně vytvářet graf krok za krokem, tento tutoriál vyžaduje určité předchozí znalosti nebo zkušenosti s D3.js. Pokud jste D3 ještě nepoužívali, doporučuji začít s některými z těchto výukových programů:

  • Příklady a dokumentace
  • Jak se naučit D3.js
  • Dostupné prstencové grafy
  • Dostupné sloupcové grafy

Část 1:Kreslení kroužků.

Nejprve budeme muset přidat kontejner do HTML a (volitelně) upravit stránku pomocí CSS. Dále nakreslíme prvek SVG pomocí JavaScriptu:

/* Define properties */
const width = 450;
const height = 450;
const margin = 40;

/* Add SVG inside <div id="activity"></div> */
const chart = d3.select('#activity').append('svg')
  .attr('width', width)
  .attr('height', height);

Nyní, když máme, můžeme do něj začít přidávat prvky. Nejprve vytvoříme skupinu, do které nakreslíme prstence, a vycentrujeme ji v rámci jejího rodiče ().

const rings = chart.append('g')
  .attr('transform', `translate(${width / 2}, ${height / 2})`);

Potom budeme muset nakreslit naše tři kroužky pro pohyb, cvičení a stání. Prozatím budeme používat následující vstupní data:

const stats = [
 {
    name: 'Moving',
    value: 122,
    goal: 350,
    perc: 0.35,
    unit: 'kcal',
    color: 'hotpink'
  }, {
    name: 'Exercising',
    value: 40,
    goal: 40,
    perc: 1.00,
    unit: 'min',
    color: 'limegreen'
  }, {
    name: 'Standing',
    value: 9,
    goal: 12,
    perc: 0.75,
    unit: 'h',
    color: 'turquoise'
  }
];

Existuje několik různých způsobů, jak nakreslit prstence, ale já jsem zvolil kreslení cest v kombinaci s funkcí d3.arc() procházením statistik a použitím perc (procento) k definování polohy začátku a konce.

rings.append('path')
    .attr('d', d3.arc()
      .innerRadius(150)
      .outerRadius(200)
      .startAngle(0)
      .endAngle(Math.PI) // full circle: Math.PI * 2
     )
    .attr('fill', 'white');

Získáme tak polovinu koblihy, která má poloměr 200 pixelů (průměr 400 pixelů), šířku pásma 50 pixelů a mezeru 2 pixely.

Když se podíváme zpět na kroužky aktivity, můžeme vidět, že každý kroužek by se měl zmenšit a mezi každým kroužkem by měla být malá mezera.

Konkrétně to znamená, že pro každý řádek dat by se vnitřní a vnější poloměr měly zmenšit.

Pokud nastavíme náš poloměr na (width - margin) / 2 (takže zabírá celý prostor SVG minus předem definovaný okraj) a šířku tahu/donutu na 50, první řádek dat bude vypadat takto:

rings.append('path')
    .attr('d', d3.arc()
      .innerRadius((width - margin) / 2 - 50)
      .outerRadius((width - margin) / 2)
      .startAngle(0)
      .endAngle(Math.PI * 2 * 0.35)
     )
    .attr('fill', 'hotpink');

Protože Math.PI * 2 nám dává celý kruh, můžeme jej vynásobit procentem splnění cíle (stat.perc), abychom vypočítali správný koncový úhel.

Pro druhé zazvonění by to muselo být:

rings.append('path')
    .attr('d', d3.arc()
      .innerRadius((width - margin) / 2 - 100 - 2)
      .outerRadius((width - margin) / 2 - 50 - 2)
      .startAngle(0)
      .endAngle(Math.PI * 2 * 1)
     )
    .attr('fill', 'limegreen');

Což můžeme zobecnit jako:

stats.forEach((stat, index) => {
  rings.append('path')
      .attr('d', d3.arc()
        .innerRadius(radius - circleStroke * (index + 1) - circleSpace * index)
        .outerRadius(radius - circleStroke * index - circleSpace * index)
        .startAngle(0)
        .endAngle(Math.PI * 2 * stat.perc)
      )
      .attr('fill', stat.color);
});

Potom budeme muset přidat podobný <path> pro tmavší, nedokončenou část kruhu. Jediné, co pro to musíme udělat, je nastavit startAnglefullCircle * stat.perc , takže začíná tam, kde končí světlý kruh, a nastavte endAngleMath.PI * 2 . Také snížíme neprůhlednost.

stats.forEach((stat, index) => {
  rings.append('path')
      .attr('d', d3.arc()
        .innerRadius(radius - circleStroke * (index + 1) - circleSpace * index)
        .outerRadius(radius - circleStroke * index - circleSpace * index)
        .startAngle(0)
        .endAngle(Math.PI * 2 * stat.perc)
      )
      .attr('fill', stat.color);

  rings.append('path')
      .attr('d', d3.arc()
        .innerRadius(radius - circleStroke * (index + 1) - circleSpace * index)
        .outerRadius(radius - circleStroke * index - circleSpace * index)
        .startAngle(Math.PI * 2 * stat.perc)
        .endAngle(Math.PI * 2)
      )
      .attr('fill', stat.color)
      .attr('opacity', 0.25);
});

Udělal jsem několik dalších úprav a přesunul část kódu do funkce drawRings, takže bych nemusel opakovat výpočty pro vnitřní a vnější poloměr. Celý kód pro tuto část můžete vidět v peru níže 👇🏻.

Pokud to posloucháme pomocí čtečky obrazovky, jako je VoiceOver nebo Narrator, neuslyšíme mnoho užitečného. Ve skutečnosti neuslyšíme vůbec nic. Je to proto, že jsme zatím kreslili pouze tvary, což čtenáři obrazovky ve skutečnosti neříká, co má dělat.

V mém předchozím tutoriálu jsme k načítání dat používali prvky, ale pro tento jsem se rozhodl pro jinou možnost:vlastnost aria-labelledby v kombinaci s prvkem a. To je inspirováno tím, jak FiveThirtyEight označili své grafy ve své prognóze prezidentských voleb v roce 2020 (tyto grafy jsem si přečetl dříve).

Budeme chtít:

  1. Nastavte roli grafu na img.
  2. Zahrňte a a do SVG a každému dejte jedinečné ID.
  3. Propojte název a popis s obrázkem přidáním aria-labelledby=”titleID descID” do grafu.

Pokud chceme napodobit nativní chování Applu, procento dokončení pro všechny tři zazvonění by se mělo číst současně. Např. „Stěhování:35 %. Cvičení:100 %. Stav:75 %.

Pro vygenerování tohoto textu vytvoříme funkci, která z pole s daty extrahuje štítek (pohyb, cvičení, stání) a hodnoty (35 %, 100 %, 75 %) a poté je vloží do věty.

const generateDescription = () => {
  return stats.map((stat) => {
    return `${stat.name}: ${stat.perc * 100}%.`;
  }).join(' ');
}

Zde procházíme objekty uvnitř pole statistik a každý z nich nahradíme řetězcem. Takže poté, co dokončíme procházení statistik, je náš výstup:

[
  'Moving: 35%.',
  'Exercising: 100%.',
  'Standing: 75%.'
]

Nakonec použijeme .join(' ') na konci k vytvoření jednoho dlouhého popisu a použijeme výstup funkce k vyplnění textu uvnitř prvku.

/* Create the chart. */
const chart = d3.select('#activity').append('svg')
  .attr('width', width)
  .attr('height', height)
  .attr('role', 'img') // SR support
  .attr('aria-labelledby', 'activityTitle activityDesc'); // SR support

/* Add title. */
chart.append('title')
  .text('Activity')
  .attr('id', 'activityTitle');

/* Add the description. */
chart.append('desc')
  .text(generateDescription)
  .attr('id', 'activityDesc');

Alternativa:Použití aria-label

Stejného výsledku můžeme dosáhnout použitím aria-label místo aria-labelledby v kombinaci se stejnou funkcí createDescription().

const chart = d3.select('#activity').append('svg')
  .attr('width', width)
  .attr('height', height)
  .attr('role', 'img') 
  .attr('aria-label', generateDescription());

Část 3:Vysvětlení dat.

Nyní tedy máme tři kruhy přístupné pro čtečku obrazovky, ale vizuálně nám toho zatím tolik neřeknou. Růžová, zelená a modrá ve skutečnosti nic neznamenají a nepůsobí dobře ani barvoslepým lidem.

Začněme přidáním ikon. Pro jednoduchost jsem nekreslil ani neimportoval žádné ikony, ale jako text jsem použil existující symboly.

/* Define icons */
const icons = {
  moving: '↦',
  exercising: '↠',
  standing: '↟'
};

/* Inside of stats.forEach(...), 
  at the end of the loop */
rings.append('text')
    .text('icons[stat.name.toLowerCase()]')
    .attr('fill', '#000')
    .attr('transform', `translate(${circleSpace}, -${(arc.outer + arc.inner) / 2 - circleSpace * (index + 2)})`)
    .attr('font-size', '1.5rem');
});

Kromě toho bychom měli vysvětlit, co znamenají barvy a symboly v legendě. Apple toto vysvětlení kombinuje se statistikami, které ukazují data podrobněji.

To nejen přidává kontext k barvám grafu, ale také zpřístupňuje stejná data v různých formátech, což také zlepšuje dostupnost.

Můžeme implementovat jeho zjednodušenou verzi přidáním prvků obsahujících štítek, součet, cíl a procentuální hodnoty. Budeme také muset přidat odpovídající ikony a barvy a upravit vertikální polohu pro každý řádek.

chart.append('text')
    .text(`${icons[stat.name.toLowerCase()]} ${stat.name}: ${stat.value}/${stat.goal}${stat.unit} (${stat.perc * 100}%)`)
    .attr('text-anchor', 'middle')
    .attr('transform', `translate(${width / 2}, ${radius * 2 + 20 * (index + 2)})`)
    .attr('fill', stat.color);

Text je přidán přímo do , nikoli do stejné skupiny jako kroužky, aby bylo možné na něj zaostřit při použití VoiceOveru.

Právě teď budou ikony v legendě stále čteny. Pokud tomu chceme zabránit, můžeme k ikonám přidat atribut aria-hidden='true' takto:

const legend = chart.append('text')
    .attr('text-anchor', 'middle')
    .attr('transform', `translate(${width / 2}, ${radius * 2 + 20 * (index + 2)})`)
    .attr('fill', stat.color);

  legend.append('tspan')
      .text(`${icons[stat.name.toLowerCase()]} `)
      .attr('aria-hidden', 'true');

  legend.append('tspan')
    .text(`${stat.name}: ${stat.value}/${stat.goal}${stat.unit} (${stat.perc * 100}%)`);

Alternativa:Rozšíření řešení aria-label

Další kroky.

Můžeme pokračovat ve stylování grafu, aby vypadal více podobně jako grafy společnosti Apple, nebo na něj použít vlastní styl. Několik dalších možných kroků by mohlo být přesunutí barevného schématu do souboru CSS, nahrazení ikon nebo přidání přechodů a stínů.

Pokud začínáte pracovat s D3.js, SVG nebo (dataviz) dostupností, zde je několik dalších článků, které vám s tím mohou pomoci:

  • Vlastnosti SVG a CSS
  • Přidání přechodů do spojnicového grafu
  • Jak psát přístupné popisy pro interaktivní grafy
  • Přidejte animaci pomocí D3.js
  • SVG a CSS
  • Přidání stínů do SVG (demo)

Neváhejte se se mnou podělit o výsledky (můžete mě označit na Twitteru nebo se o tom zmínit v komentářích zde), pokud pomocí tohoto návodu vytvoříte něco podobného nebo máte jiný způsob řešení 👀

Bonusová řešení:

Jiný typ vstupu.

Procházejte kroužky aktivit.