Coverage for src / tracekit / visualization / accessibility.py: 100%
144 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Accessibility utilities for TraceKit visualizations.
3This module provides accessibility features for visualizations including
4colorblind-safe palettes, alt-text generation, and keyboard navigation support.
7Example:
8 >>> from tracekit.visualization.accessibility import (
9 ... get_colorblind_palette,
10 ... generate_alt_text,
11 ... KeyboardHandler
12 ... )
13 >>> palette = get_colorblind_palette("viridis")
14 >>> alt_text = generate_alt_text(trace, "Time-domain waveform")
16References:
17 - Colorblind-safe palette design (Brettel 1997)
18 - WCAG 2.1 accessibility guidelines
19 - WAI-ARIA best practices
20"""
22from __future__ import annotations
24from typing import TYPE_CHECKING, Any, Literal
26import matplotlib.pyplot as plt
27import numpy as np
29if TYPE_CHECKING:
30 from collections.abc import Callable
32 from matplotlib.axes import Axes
33 from matplotlib.figure import Figure
34 from numpy.typing import NDArray
37# Line style patterns for multi-line plots (ACC-001)
38LINE_STYLES = ["solid", "dashed", "dotted", "dashdot"]
40# Pass/fail symbols (ACC-001)
41PASS_SYMBOL = "✓"
42FAIL_SYMBOL = "✗"
45def get_colorblind_palette(
46 name: Literal["viridis", "cividis", "plasma", "inferno", "magma"] = "viridis",
47) -> str:
48 """Get colorblind-safe colormap name.
50 : All visualizations use colorblind-safe palettes by default.
51 Returns matplotlib colormap names that are perceptually uniform and colorblind-safe.
53 Args:
54 name: Colormap name. Options:
55 - "viridis": Default, excellent for sequential data
56 - "cividis": Optimized for colorblind users
57 - "plasma": High contrast sequential
58 - "inferno": Warm sequential
59 - "magma": Dark to bright sequential
61 Returns:
62 Matplotlib colormap name string
64 Raises:
65 ValueError: If colormap name is not recognized
67 Example:
68 >>> import matplotlib.pyplot as plt
69 >>> from tracekit.visualization.accessibility import get_colorblind_palette
70 >>> cmap = get_colorblind_palette("viridis")
71 >>> plt.plot([1, 2, 3], [1, 4, 2], color=plt.get_cmap(cmap)(0.5))
73 References:
74 ACC-001: Colorblind-Safe Visualization Palette
75 Matplotlib perceptually uniform colormaps
76 """
77 valid_names = ["viridis", "cividis", "plasma", "inferno", "magma"]
78 if name not in valid_names:
79 raise ValueError(f"Unknown colormap: {name}. Valid options: {', '.join(valid_names)}")
80 return name
83def get_multi_line_styles(n_lines: int) -> list[tuple[tuple[float, float, float, float], str]]:
84 """Get distinct line styles and colors for multi-line plots.
86 : Multi-line plots use distinct line styles in addition to colors.
87 Combines colorblind-safe colors with varied line styles for maximum distinguishability.
89 Args:
90 n_lines: Number of lines to style
92 Returns:
93 List of (color, linestyle) tuples where color is RGBA tuple
95 Example:
96 >>> from tracekit.visualization.accessibility import get_multi_line_styles
97 >>> import matplotlib.pyplot as plt
98 >>> styles = get_multi_line_styles(4)
99 >>> for i, (color, ls) in enumerate(styles):
100 ... plt.plot([1, 2, 3], [i, i+1, i+2], color=color, linestyle=ls)
102 References:
103 ACC-001: Colorblind-Safe Visualization Palette
104 """
105 # Use viridis colormap for colorblind-safe colors
106 cmap = plt.get_cmap("viridis")
107 colors = [cmap(i / max(n_lines - 1, 1)) for i in range(n_lines)]
109 # Cycle through line styles
110 styles: list[tuple[tuple[float, float, float, float], str]] = []
111 for i in range(n_lines):
112 linestyle = LINE_STYLES[i % len(LINE_STYLES)]
113 # Colors from colormap are RGBA tuples
114 rgba_color = tuple(colors[i]) # type: ignore[arg-type]
115 styles.append((rgba_color, linestyle)) # type: ignore[arg-type]
117 return styles
120def format_pass_fail(
121 passed: bool,
122 *,
123 use_color: bool = True,
124 use_symbols: bool = True,
125) -> str:
126 """Format pass/fail status with symbols and optional colors.
128 : Pass/fail uses symbols (✓/✗) not just red/green.
129 Ensures accessibility by using symbols in addition to or instead of colors.
131 Args:
132 passed: Whether the test passed
133 use_color: Include ANSI color codes (default: True)
134 use_symbols: Include checkmark/cross symbols (default: True)
136 Returns:
137 Formatted string with symbol and/or color
139 Example:
140 >>> from tracekit.visualization.accessibility import format_pass_fail
141 >>> print(format_pass_fail(True))
142 ✓ PASS
143 >>> print(format_pass_fail(False))
144 ✗ FAIL
146 References:
147 ACC-001: Colorblind-Safe Visualization Palette
148 """
149 if passed:
150 symbol = PASS_SYMBOL if use_symbols else ""
151 text = "PASS"
152 color_code = "\033[92m" if use_color else "" # Green
153 else:
154 symbol = FAIL_SYMBOL if use_symbols else ""
155 text = "FAIL"
156 color_code = "\033[91m" if use_color else "" # Red
158 reset_code = "\033[0m" if use_color else ""
160 if use_symbols:
161 return f"{color_code}{symbol} {text}{reset_code}"
162 else:
163 return f"{color_code}{text}{reset_code}"
166def generate_alt_text(
167 data: NDArray[np.float64] | dict[str, Any],
168 plot_type: str,
169 *,
170 title: str | None = None,
171 x_label: str = "Time",
172 y_label: str = "Amplitude",
173 sample_rate: float | None = None,
174) -> str:
175 """Generate descriptive alt-text for a plot.
177 : Every plot has alt_text property describing content.
178 Provides text-based summary for screen readers and accessibility tools.
180 Args:
181 data: Signal data array or statistics dictionary
182 plot_type: Type of plot ("waveform", "spectrum", "histogram", "eye_diagram")
183 title: Plot title (optional)
184 x_label: X-axis label
185 y_label: Y-axis label
186 sample_rate: Sample rate in Hz (for time calculations)
188 Returns:
189 Descriptive alt-text string
191 Example:
192 >>> import numpy as np
193 >>> from tracekit.visualization.accessibility import generate_alt_text
194 >>> signal = np.sin(2 * np.pi * 1e3 * np.linspace(0, 1e-3, 1000))
195 >>> alt_text = generate_alt_text(signal, "waveform", title="1 kHz sine wave")
196 >>> print(alt_text)
197 1 kHz sine wave. Waveform plot showing Time vs Amplitude...
199 References:
200 ACC-002: Text Alternatives for Visualizations
201 WCAG 2.1 Section 1.1.1 (Non-text Content)
202 """
203 parts = []
205 # Add title if provided
206 if title:
207 parts.append(f"{title}.")
209 # Describe plot type
210 parts.append(f"{plot_type.replace('_', ' ').title()} plot showing {x_label} vs {y_label}.")
212 # Add data statistics
213 if isinstance(data, dict):
214 # Already have statistics
215 stats = data
216 else:
217 # Calculate statistics from array
218 stats = {
219 "min": float(np.min(data)),
220 "max": float(np.max(data)),
221 "mean": float(np.mean(data)),
222 "std": float(np.std(data)),
223 "n_samples": len(data),
224 }
226 # Format statistics
227 if "n_samples" in stats:
228 parts.append(f"Contains {stats['n_samples']} samples.")
230 if "min" in stats and "max" in stats:
231 parts.append(f"Range: {stats['min']:.3g} to {stats['max']:.3g} {y_label}.")
233 if "mean" in stats:
234 parts.append(f"Mean: {stats['mean']:.3g}.")
236 if "std" in stats:
237 parts.append(f"Standard deviation: {stats['std']:.3g}.")
239 # Add duration if sample rate provided
240 if sample_rate is not None and "n_samples" in stats:
241 duration_s = stats["n_samples"] / sample_rate
242 if duration_s < 1e-6:
243 duration_str = f"{duration_s * 1e9:.2f} ns"
244 elif duration_s < 1e-3:
245 duration_str = f"{duration_s * 1e6:.2f} µs"
246 elif duration_s < 1:
247 duration_str = f"{duration_s * 1e3:.2f} ms"
248 else:
249 duration_str = f"{duration_s:.2f} s"
250 parts.append(f"Duration: {duration_str}.")
252 return " ".join(parts)
255def add_plot_aria_attributes(
256 fig: Figure,
257 alt_text: str,
258 *,
259 role: str = "img",
260 label: str | None = None,
261) -> None:
262 """Add ARIA attributes to matplotlib figure for accessibility.
264 : HTML reports include aria-describedby for plots.
265 Adds WAI-ARIA attributes to figure metadata for screen reader support.
267 Args:
268 fig: Matplotlib figure object
269 alt_text: Alternative text description
270 role: ARIA role (default: "img")
271 label: ARIA label (optional)
273 Example:
274 >>> import matplotlib.pyplot as plt
275 >>> from tracekit.visualization.accessibility import (
276 ... add_plot_aria_attributes,
277 ... generate_alt_text
278 ... )
279 >>> fig, ax = plt.subplots()
280 >>> ax.plot([1, 2, 3], [1, 4, 2])
281 >>> alt_text = generate_alt_text([1, 4, 2], "waveform")
282 >>> add_plot_aria_attributes(fig, alt_text)
284 References:
285 ACC-002: Text Alternatives for Visualizations
286 WAI-ARIA 1.2 specification
287 """
288 # Store as figure metadata
289 if not hasattr(fig, "_tracekit_accessibility"):
290 fig._tracekit_accessibility = {} # type: ignore[attr-defined]
292 fig._tracekit_accessibility["alt_text"] = alt_text # type: ignore[attr-defined]
293 fig._tracekit_accessibility["aria_role"] = role # type: ignore[attr-defined]
295 if label:
296 fig._tracekit_accessibility["aria_label"] = label # type: ignore[attr-defined]
299class KeyboardHandler:
300 """Keyboard navigation handler for interactive plots.
302 : Interactive visualizations are fully keyboard-navigable.
303 Provides standard keyboard controls for plot interaction.
305 Keyboard shortcuts:
306 - Tab: Navigate between plot elements
307 - Arrow keys: Move cursors/markers
308 - Enter: Select/activate element
309 - Escape: Close modals/menus
310 - +/-: Zoom in/out
311 - Home/End: Jump to start/end
312 - Space: Toggle play/pause (for animations)
314 Args:
315 fig: Matplotlib figure to attach handlers to
316 axes: Axes object for cursor/marker operations
318 Example:
319 >>> import matplotlib.pyplot as plt
320 >>> from tracekit.visualization.accessibility import KeyboardHandler
321 >>> fig, ax = plt.subplots()
322 >>> ax.plot([1, 2, 3], [1, 4, 2])
323 >>> handler = KeyboardHandler(fig, ax)
324 >>> handler.enable()
325 >>> plt.show()
327 References:
328 ACC-003: Keyboard Navigation for Interactive Plots
329 WAI-ARIA Authoring Practices 1.2
330 """
332 def __init__(self, fig: Figure, axes: Axes) -> None:
333 """Initialize keyboard handler.
335 Args:
336 fig: Matplotlib figure
337 axes: Axes for operations
338 """
339 self.fig = fig
340 self.axes = axes
341 self.cursor_position: float = 0.0
342 self.cursor_line: Any = None
343 self.enabled: bool = False
344 self._connection_id: int | None = None
346 # Callback registry
347 self.on_cursor_move: Callable[[float], None] | None = None
348 self.on_select: Callable[[], None] | None = None
349 self.on_escape: Callable[[], None] | None = None
351 def enable(self) -> None:
352 """Enable keyboard navigation.
354 Connects keyboard event handlers to the figure.
356 Example:
357 >>> handler = KeyboardHandler(fig, ax)
358 >>> handler.enable()
360 References:
361 ACC-003: Keyboard Navigation for Interactive Plots
362 """
363 if not self.enabled:
364 self._connection_id = self.fig.canvas.mpl_connect("key_press_event", self._on_key_press)
365 self.enabled = True
367 def disable(self) -> None:
368 """Disable keyboard navigation.
370 Disconnects keyboard event handlers.
372 Example:
373 >>> handler.disable()
375 References:
376 ACC-003: Keyboard Navigation for Interactive Plots
377 """
378 if self.enabled and self._connection_id is not None:
379 self.fig.canvas.mpl_disconnect(self._connection_id)
380 self._connection_id = None
381 self.enabled = False
383 def _on_key_press(self, event: Any) -> None:
384 """Handle keyboard events.
386 Args:
387 event: Matplotlib key press event
389 References:
390 ACC-003: Keyboard Navigation for Interactive Plots
391 """
392 if event.key is None:
393 return
395 # Arrow keys: move cursor
396 if event.key in ("left", "right"):
397 self._move_cursor(event.key)
399 # Enter: select/activate
400 elif event.key == "enter":
401 if self.on_select:
402 self.on_select()
404 # Escape: close/cancel
405 elif event.key == "escape":
406 if self.on_escape:
407 self.on_escape()
409 # +/-: zoom
410 elif event.key in ("+", "="):
411 self._zoom(1.2)
412 elif event.key in ("-", "_"):
413 self._zoom(0.8)
415 # Home/End: jump to edges
416 elif event.key == "home":
417 self._jump_to_start()
418 elif event.key == "end":
419 self._jump_to_end()
421 def _move_cursor(self, direction: str) -> None:
422 """Move cursor left or right.
424 Args:
425 direction: "left" or "right"
427 References:
428 ACC-003: Keyboard Navigation for Interactive Plots
429 """
430 xlim = self.axes.get_xlim()
431 step = (xlim[1] - xlim[0]) * 0.01 # 1% of range
433 if direction == "left":
434 self.cursor_position = max(xlim[0], self.cursor_position - step)
435 else:
436 self.cursor_position = min(xlim[1], self.cursor_position + step)
438 self._update_cursor()
440 if self.on_cursor_move:
441 self.on_cursor_move(self.cursor_position)
443 def _update_cursor(self) -> None:
444 """Update cursor line on plot.
446 References:
447 ACC-003: Keyboard Navigation for Interactive Plots
448 """
449 ylim = self.axes.get_ylim()
451 if self.cursor_line is None:
452 # Create cursor line
453 (self.cursor_line,) = self.axes.plot(
454 [self.cursor_position, self.cursor_position],
455 ylim,
456 "r--",
457 linewidth=2,
458 label="Cursor",
459 )
460 else:
461 # Update existing cursor
462 self.cursor_line.set_xdata([self.cursor_position, self.cursor_position])
464 self.fig.canvas.draw_idle()
466 def _zoom(self, factor: float) -> None:
467 """Zoom in or out.
469 Args:
470 factor: Zoom factor (>1 = zoom in, <1 = zoom out)
472 References:
473 ACC-003: Keyboard Navigation for Interactive Plots
474 """
475 xlim = self.axes.get_xlim()
476 ylim = self.axes.get_ylim()
478 x_center = (xlim[0] + xlim[1]) / 2
479 y_center = (ylim[0] + ylim[1]) / 2
481 x_range = (xlim[1] - xlim[0]) / factor
482 y_range = (ylim[1] - ylim[0]) / factor
484 self.axes.set_xlim(x_center - x_range / 2, x_center + x_range / 2)
485 self.axes.set_ylim(y_center - y_range / 2, y_center + y_range / 2)
487 self.fig.canvas.draw_idle()
489 def _jump_to_start(self) -> None:
490 """Jump cursor to start of plot.
492 References:
493 ACC-003: Keyboard Navigation for Interactive Plots
494 """
495 xlim = self.axes.get_xlim()
496 self.cursor_position = xlim[0]
497 self._update_cursor()
499 if self.on_cursor_move:
500 self.on_cursor_move(self.cursor_position)
502 def _jump_to_end(self) -> None:
503 """Jump cursor to end of plot.
505 References:
506 ACC-003: Keyboard Navigation for Interactive Plots
507 """
508 xlim = self.axes.get_xlim()
509 self.cursor_position = xlim[1]
510 self._update_cursor()
512 if self.on_cursor_move:
513 self.on_cursor_move(self.cursor_position)
516__all__ = [
517 "FAIL_SYMBOL",
518 "LINE_STYLES",
519 "PASS_SYMBOL",
520 "KeyboardHandler",
521 "add_plot_aria_attributes",
522 "format_pass_fail",
523 "generate_alt_text",
524 "get_colorblind_palette",
525 "get_multi_line_styles",
526]