claritykit-svelte
Version:
A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility
571 lines (570 loc) • 20.8 kB
JavaScript
// Advanced accessibility utilities for data visualizations in ClarityKit
import { announceStatus } from '../../../utils/accessibility';
/**
* Generate comprehensive chart descriptions for screen readers
*/
export function generateChartDescription(data, chartType, title) {
const summary = generateDataSummary(data, chartType);
const trends = identifyTrends(data);
const keyInsights = generateKeyInsights(data);
const dataRange = calculateDataRange(data);
return {
title,
type: chartType,
summary,
trends,
keyInsights,
dataRange,
accessibility: {
keyboardInstructions: getKeyboardInstructions(chartType),
screenReaderInstructions: getScreenReaderInstructions(chartType),
alternativeFormats: ['Data table', 'CSV export', 'Text summary']
}
};
}
function generateDataSummary(data, chartType) {
const count = data.length;
const hasNumericData = data.some(d => typeof d.value === 'number');
if (!hasNumericData) {
return `${chartType} chart with ${count} categorical data point${count !== 1 ? 's' : ''}`;
}
const numericValues = data
.map(d => d.value)
.filter((v) => typeof v === 'number');
const sum = numericValues.reduce((a, b) => a + b, 0);
const avg = sum / numericValues.length;
const min = Math.min(...numericValues);
const max = Math.max(...numericValues);
return `${chartType} chart with ${count} data points. Values range from ${min.toLocaleString()} to ${max.toLocaleString()}, with an average of ${avg.toLocaleString()}.`;
}
function identifyTrends(data) {
const trends = [];
// Check for increasing/decreasing trends in numeric data
const numericData = data.filter(d => typeof d.value === 'number');
if (numericData.length >= 3) {
const values = numericData.map(d => d.value);
const firstHalf = values.slice(0, Math.floor(values.length / 2));
const secondHalf = values.slice(Math.ceil(values.length / 2));
const firstAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
const secondAvg = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length;
if (secondAvg > firstAvg * 1.1) {
trends.push('Overall increasing trend');
}
else if (secondAvg < firstAvg * 0.9) {
trends.push('Overall decreasing trend');
}
else {
trends.push('Relatively stable values');
}
}
// Identify outliers
if (numericData.length >= 5) {
const values = numericData.map(d => d.value);
const sorted = [...values].sort((a, b) => a - b);
const q1 = sorted[Math.floor(sorted.length * 0.25)];
const q3 = sorted[Math.floor(sorted.length * 0.75)];
const iqr = q3 - q1;
const lowerBound = q1 - 1.5 * iqr;
const upperBound = q3 + 1.5 * iqr;
const outliers = values.filter(v => v < lowerBound || v > upperBound);
if (outliers.length > 0) {
trends.push(`${outliers.length} outlier${outliers.length !== 1 ? 's' : ''} detected`);
}
}
return trends;
}
function generateKeyInsights(data) {
const insights = [];
// Find highest and lowest values
const numericData = data.filter(d => typeof d.value === 'number');
if (numericData.length > 0) {
const values = numericData.map(d => ({ label: d.label, value: d.value }));
const highest = values.reduce((max, item) => item.value > max.value ? item : max);
const lowest = values.reduce((min, item) => item.value < min.value ? item : min);
insights.push(`Highest value: ${highest.label} at ${highest.value.toLocaleString()}`);
insights.push(`Lowest value: ${lowest.label} at ${lowest.value.toLocaleString()}`);
}
// Category distribution
const categories = data.map(d => d.category).filter(Boolean);
if (categories.length > 0) {
const categoryCount = categories.reduce((acc, cat) => {
acc[cat] = (acc[cat] || 0) + 1;
return acc;
}, {});
const mostCommon = Object.entries(categoryCount)
.sort(([, a], [, b]) => b - a)[0];
insights.push(`Most common category: ${mostCommon[0]} (${mostCommon[1]} items)`);
}
return insights;
}
function calculateDataRange(data) {
const numericValues = data
.map(d => d.value)
.filter((v) => typeof v === 'number');
if (numericValues.length === 0) {
return {
min: 0,
max: 0,
count: data.length
};
}
return {
min: Math.min(...numericValues),
max: Math.max(...numericValues),
count: data.length
};
}
function getKeyboardInstructions(chartType) {
const baseInstructions = [
'Use Tab to navigate between interactive elements',
'Use arrow keys to navigate data points',
'Press Enter to select or activate elements',
'Press Escape to exit selection mode'
];
switch (chartType.toLowerCase()) {
case 'line':
case 'area':
return [
...baseInstructions,
'Use Left/Right arrows to move along the data series',
'Use Up/Down arrows to jump between multiple series'
];
case 'bar':
case 'column':
return [
...baseInstructions,
'Use Left/Right arrows to move between bars',
'Use Up/Down arrows to move between categories'
];
case 'pie':
case 'donut':
return [
...baseInstructions,
'Use Left/Right arrows to move between slices',
'Press Space to highlight a slice'
];
case 'scatter':
return [
...baseInstructions,
'Use arrow keys to navigate between data points',
'Press Ctrl+arrow keys for faster navigation'
];
case 'network':
return [
...baseInstructions,
'Use arrow keys to navigate between nodes',
'Press Enter to select a node and its connections',
'Use +/- to zoom in/out',
'Press F to search for nodes'
];
default:
return baseInstructions;
}
}
function getScreenReaderInstructions(chartType) {
return [
'This chart provides both visual and tabular data representations',
'Navigate to the data table below for detailed information',
'Chart summary and trends are announced when entering the chart area',
'Use landmark navigation to jump between chart sections'
];
}
/**
* Create ARIA live region for dynamic chart updates
*/
export function createLiveRegion(container, id) {
let liveRegion = container.querySelector(`#${id}`);
if (!liveRegion) {
liveRegion = document.createElement('div');
liveRegion.id = id;
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.className = 'sr-only';
liveRegion.style.cssText = `
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
`;
container.appendChild(liveRegion);
}
return liveRegion;
}
/**
* Announce chart updates to screen readers
*/
export function announceChartUpdate(liveRegion, updateType, details) {
const messages = {
data: `Chart data updated. ${details}`,
selection: `Selection changed. ${details}`,
zoom: `Zoom level changed. ${details}`,
filter: `Filter applied. ${details}`
};
const message = messages[updateType];
liveRegion.textContent = message;
// Clear after announcement
setTimeout(() => {
if (liveRegion.textContent === message) {
liveRegion.textContent = '';
}
}, 1000);
}
/**
* Create accessible data table alternative
*/
export function createDataTable(data, caption, container) {
const table = document.createElement('table');
table.className = 'chart-data-table sr-only';
table.setAttribute('role', 'table');
table.setAttribute('aria-label', 'Chart data in tabular format');
// Caption
const captionEl = document.createElement('caption');
captionEl.textContent = caption;
table.appendChild(captionEl);
// Header
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
const labelHeader = document.createElement('th');
labelHeader.textContent = 'Label';
labelHeader.setAttribute('scope', 'col');
headerRow.appendChild(labelHeader);
const valueHeader = document.createElement('th');
valueHeader.textContent = 'Value';
valueHeader.setAttribute('scope', 'col');
headerRow.appendChild(valueHeader);
// Add category column if categories exist
const hasCategories = data.some(d => d.category);
if (hasCategories) {
const categoryHeader = document.createElement('th');
categoryHeader.textContent = 'Category';
categoryHeader.setAttribute('scope', 'col');
headerRow.appendChild(categoryHeader);
}
thead.appendChild(headerRow);
table.appendChild(thead);
// Body
const tbody = document.createElement('tbody');
data.forEach((point, index) => {
const row = document.createElement('tr');
const labelCell = document.createElement('td');
labelCell.textContent = point.label;
row.appendChild(labelCell);
const valueCell = document.createElement('td');
if (typeof point.value === 'number') {
valueCell.textContent = point.value.toLocaleString();
}
else if (point.value instanceof Date) {
valueCell.textContent = point.value.toLocaleDateString();
}
else {
valueCell.textContent = String(point.value);
}
row.appendChild(valueCell);
if (hasCategories) {
const categoryCell = document.createElement('td');
categoryCell.textContent = point.category || '';
row.appendChild(categoryCell);
}
tbody.appendChild(row);
});
table.appendChild(tbody);
container.appendChild(table);
return table;
}
/**
* Simple data sonification for accessibility
*/
export class DataSonification {
constructor() {
Object.defineProperty(this, "audioContext", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "isEnabled", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
// Check if Web Audio API is supported
if (typeof window !== 'undefined' && 'AudioContext' in window) {
this.audioContext = new AudioContext();
this.isEnabled = true;
}
}
/**
* Convert data values to audio frequencies
*/
valueToFrequency(value, min, max) {
// Map value to frequency range (200Hz to 800Hz)
const minFreq = 200;
const maxFreq = 800;
const normalized = (value - min) / (max - min);
return minFreq + (normalized * (maxFreq - minFreq));
}
/**
* Play a tone for a data point
*/
playTone(value, min, max, duration = 200) {
if (!this.isEnabled || !this.audioContext)
return;
const frequency = this.valueToFrequency(value, min, max);
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
oscillator.frequency.setValueAtTime(frequency, this.audioContext.currentTime);
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.1, this.audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, this.audioContext.currentTime + duration / 1000);
oscillator.start(this.audioContext.currentTime);
oscillator.stop(this.audioContext.currentTime + duration / 1000);
}
/**
* Play data series as a sequence of tones
*/
playDataSeries(data, intervalMs = 300) {
const numericData = data.filter(d => typeof d.value === 'number');
if (numericData.length === 0)
return;
const values = numericData.map(d => d.value);
const min = Math.min(...values);
const max = Math.max(...values);
values.forEach((value, index) => {
setTimeout(() => {
this.playTone(value, min, max);
// Announce the data point
announceStatus(`Data point ${index + 1}: ${numericData[index].label}, value ${value}`);
}, index * intervalMs);
});
}
/**
* Enable/disable sonification
*/
setEnabled(enabled) {
this.isEnabled = enabled && this.audioContext !== null;
}
/**
* Resume audio context (required after user interaction)
*/
resume() {
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
}
}
/**
* High contrast mode utilities
*/
export class HighContrastMode {
constructor() {
Object.defineProperty(this, "isEnabled", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "originalColors", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
}
static getInstance() {
if (!HighContrastMode.instance) {
HighContrastMode.instance = new HighContrastMode();
}
return HighContrastMode.instance;
}
enable(container) {
if (this.isEnabled)
return;
this.isEnabled = true;
container.classList.add('high-contrast-mode');
// Store original colors and apply high contrast
const elements = container.querySelectorAll('[data-chart-element]');
elements.forEach((el) => {
const htmlEl = el;
const computedStyle = getComputedStyle(htmlEl);
// Store original colors
this.originalColors.set(`${htmlEl.dataset.chartElement}-color`, computedStyle.color);
this.originalColors.set(`${htmlEl.dataset.chartElement}-bg`, computedStyle.backgroundColor);
// Apply high contrast colors
if (htmlEl.dataset.chartElement === 'data-point') {
htmlEl.style.color = '#000000';
htmlEl.style.backgroundColor = '#FFFFFF';
htmlEl.style.border = '2px solid #000000';
}
});
announceStatus('High contrast mode enabled');
}
disable(container) {
if (!this.isEnabled)
return;
this.isEnabled = false;
container.classList.remove('high-contrast-mode');
// Restore original colors
const elements = container.querySelectorAll('[data-chart-element]');
elements.forEach((el) => {
const htmlEl = el;
const originalColor = this.originalColors.get(`${htmlEl.dataset.chartElement}-color`);
const originalBg = this.originalColors.get(`${htmlEl.dataset.chartElement}-bg`);
if (originalColor)
htmlEl.style.color = originalColor;
if (originalBg)
htmlEl.style.backgroundColor = originalBg;
htmlEl.style.border = '';
});
this.originalColors.clear();
announceStatus('High contrast mode disabled');
}
toggle(container) {
if (this.isEnabled) {
this.disable(container);
}
else {
this.enable(container);
}
}
}
/**
* Voice navigation for charts
*/
export class VoiceNavigation {
constructor() {
Object.defineProperty(this, "recognition", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "isListening", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "commands", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
// Check if Web Speech API is supported
if (typeof window !== 'undefined' && 'webkitSpeechRecognition' in window) {
this.recognition = new window.webkitSpeechRecognition();
this.recognition.continuous = true;
this.recognition.interimResults = false;
this.recognition.lang = 'en-US';
this.recognition.onresult = (event) => {
const transcript = event.results[event.results.length - 1][0].transcript.toLowerCase().trim();
this.processCommand(transcript);
};
this.recognition.onerror = (event) => {
console.error('Speech recognition error:', event.error);
announceStatus('Voice navigation error occurred');
};
}
}
/**
* Register voice commands
*/
registerCommand(phrase, callback) {
this.commands.set(phrase.toLowerCase(), callback);
}
/**
* Start listening for voice commands
*/
startListening() {
if (!this.recognition || this.isListening)
return;
try {
this.recognition.start();
this.isListening = true;
announceStatus('Voice navigation activated. Say commands like "show data table" or "zoom in"');
}
catch (error) {
console.error('Failed to start voice recognition:', error);
}
}
/**
* Stop listening for voice commands
*/
stopListening() {
if (!this.recognition || !this.isListening)
return;
try {
this.recognition.stop();
this.isListening = false;
announceStatus('Voice navigation deactivated');
}
catch (error) {
console.error('Failed to stop voice recognition:', error);
}
}
/**
* Process recognized speech command
*/
processCommand(transcript) {
// Check for exact matches first
if (this.commands.has(transcript)) {
this.commands.get(transcript)();
return;
}
// Check for partial matches
for (const [phrase, callback] of this.commands.entries()) {
if (transcript.includes(phrase)) {
callback();
return;
}
}
announceStatus(`Command "${transcript}" not recognized`);
}
/**
* Register default chart commands
*/
registerDefaultCommands(chartElement) {
this.registerCommand('show data table', () => {
const table = chartElement.querySelector('.chart-data-table');
if (table) {
table.classList.remove('sr-only');
table.focus();
announceStatus('Data table shown');
}
});
this.registerCommand('hide data table', () => {
const table = chartElement.querySelector('.chart-data-table');
if (table) {
table.classList.add('sr-only');
announceStatus('Data table hidden');
}
});
this.registerCommand('high contrast', () => {
HighContrastMode.getInstance().toggle(chartElement);
});
this.registerCommand('zoom in', () => {
chartElement.dispatchEvent(new CustomEvent('zoomIn'));
announceStatus('Zooming in');
});
this.registerCommand('zoom out', () => {
chartElement.dispatchEvent(new CustomEvent('zoomOut'));
announceStatus('Zooming out');
});
this.registerCommand('reset view', () => {
chartElement.dispatchEvent(new CustomEvent('resetView'));
announceStatus('View reset');
});
this.registerCommand('play data', () => {
chartElement.dispatchEvent(new CustomEvent('playDataSonification'));
announceStatus('Playing data as audio');
});
}
}