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

1"""Accessibility utilities for TraceKit visualizations. 

2 

3This module provides accessibility features for visualizations including 

4colorblind-safe palettes, alt-text generation, and keyboard navigation support. 

5 

6 

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") 

15 

16References: 

17 - Colorblind-safe palette design (Brettel 1997) 

18 - WCAG 2.1 accessibility guidelines 

19 - WAI-ARIA best practices 

20""" 

21 

22from __future__ import annotations 

23 

24from typing import TYPE_CHECKING, Any, Literal 

25 

26import matplotlib.pyplot as plt 

27import numpy as np 

28 

29if TYPE_CHECKING: 

30 from collections.abc import Callable 

31 

32 from matplotlib.axes import Axes 

33 from matplotlib.figure import Figure 

34 from numpy.typing import NDArray 

35 

36 

37# Line style patterns for multi-line plots (ACC-001) 

38LINE_STYLES = ["solid", "dashed", "dotted", "dashdot"] 

39 

40# Pass/fail symbols (ACC-001) 

41PASS_SYMBOL = "✓" 

42FAIL_SYMBOL = "✗" 

43 

44 

45def get_colorblind_palette( 

46 name: Literal["viridis", "cividis", "plasma", "inferno", "magma"] = "viridis", 

47) -> str: 

48 """Get colorblind-safe colormap name. 

49 

50 : All visualizations use colorblind-safe palettes by default. 

51 Returns matplotlib colormap names that are perceptually uniform and colorblind-safe. 

52 

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 

60 

61 Returns: 

62 Matplotlib colormap name string 

63 

64 Raises: 

65 ValueError: If colormap name is not recognized 

66 

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)) 

72 

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 

81 

82 

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. 

85 

86 : Multi-line plots use distinct line styles in addition to colors. 

87 Combines colorblind-safe colors with varied line styles for maximum distinguishability. 

88 

89 Args: 

90 n_lines: Number of lines to style 

91 

92 Returns: 

93 List of (color, linestyle) tuples where color is RGBA tuple 

94 

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) 

101 

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)] 

108 

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] 

116 

117 return styles 

118 

119 

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. 

127 

128 : Pass/fail uses symbols (✓/✗) not just red/green. 

129 Ensures accessibility by using symbols in addition to or instead of colors. 

130 

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) 

135 

136 Returns: 

137 Formatted string with symbol and/or color 

138 

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 

145 

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 

157 

158 reset_code = "\033[0m" if use_color else "" 

159 

160 if use_symbols: 

161 return f"{color_code}{symbol} {text}{reset_code}" 

162 else: 

163 return f"{color_code}{text}{reset_code}" 

164 

165 

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. 

176 

177 : Every plot has alt_text property describing content. 

178 Provides text-based summary for screen readers and accessibility tools. 

179 

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) 

187 

188 Returns: 

189 Descriptive alt-text string 

190 

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... 

198 

199 References: 

200 ACC-002: Text Alternatives for Visualizations 

201 WCAG 2.1 Section 1.1.1 (Non-text Content) 

202 """ 

203 parts = [] 

204 

205 # Add title if provided 

206 if title: 

207 parts.append(f"{title}.") 

208 

209 # Describe plot type 

210 parts.append(f"{plot_type.replace('_', ' ').title()} plot showing {x_label} vs {y_label}.") 

211 

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 } 

225 

226 # Format statistics 

227 if "n_samples" in stats: 

228 parts.append(f"Contains {stats['n_samples']} samples.") 

229 

230 if "min" in stats and "max" in stats: 

231 parts.append(f"Range: {stats['min']:.3g} to {stats['max']:.3g} {y_label}.") 

232 

233 if "mean" in stats: 

234 parts.append(f"Mean: {stats['mean']:.3g}.") 

235 

236 if "std" in stats: 

237 parts.append(f"Standard deviation: {stats['std']:.3g}.") 

238 

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}.") 

251 

252 return " ".join(parts) 

253 

254 

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. 

263 

264 : HTML reports include aria-describedby for plots. 

265 Adds WAI-ARIA attributes to figure metadata for screen reader support. 

266 

267 Args: 

268 fig: Matplotlib figure object 

269 alt_text: Alternative text description 

270 role: ARIA role (default: "img") 

271 label: ARIA label (optional) 

272 

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) 

283 

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] 

291 

292 fig._tracekit_accessibility["alt_text"] = alt_text # type: ignore[attr-defined] 

293 fig._tracekit_accessibility["aria_role"] = role # type: ignore[attr-defined] 

294 

295 if label: 

296 fig._tracekit_accessibility["aria_label"] = label # type: ignore[attr-defined] 

297 

298 

299class KeyboardHandler: 

300 """Keyboard navigation handler for interactive plots. 

301 

302 : Interactive visualizations are fully keyboard-navigable. 

303 Provides standard keyboard controls for plot interaction. 

304 

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) 

313 

314 Args: 

315 fig: Matplotlib figure to attach handlers to 

316 axes: Axes object for cursor/marker operations 

317 

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() 

326 

327 References: 

328 ACC-003: Keyboard Navigation for Interactive Plots 

329 WAI-ARIA Authoring Practices 1.2 

330 """ 

331 

332 def __init__(self, fig: Figure, axes: Axes) -> None: 

333 """Initialize keyboard handler. 

334 

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 

345 

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 

350 

351 def enable(self) -> None: 

352 """Enable keyboard navigation. 

353 

354 Connects keyboard event handlers to the figure. 

355 

356 Example: 

357 >>> handler = KeyboardHandler(fig, ax) 

358 >>> handler.enable() 

359 

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 

366 

367 def disable(self) -> None: 

368 """Disable keyboard navigation. 

369 

370 Disconnects keyboard event handlers. 

371 

372 Example: 

373 >>> handler.disable() 

374 

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 

382 

383 def _on_key_press(self, event: Any) -> None: 

384 """Handle keyboard events. 

385 

386 Args: 

387 event: Matplotlib key press event 

388 

389 References: 

390 ACC-003: Keyboard Navigation for Interactive Plots 

391 """ 

392 if event.key is None: 

393 return 

394 

395 # Arrow keys: move cursor 

396 if event.key in ("left", "right"): 

397 self._move_cursor(event.key) 

398 

399 # Enter: select/activate 

400 elif event.key == "enter": 

401 if self.on_select: 

402 self.on_select() 

403 

404 # Escape: close/cancel 

405 elif event.key == "escape": 

406 if self.on_escape: 

407 self.on_escape() 

408 

409 # +/-: zoom 

410 elif event.key in ("+", "="): 

411 self._zoom(1.2) 

412 elif event.key in ("-", "_"): 

413 self._zoom(0.8) 

414 

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() 

420 

421 def _move_cursor(self, direction: str) -> None: 

422 """Move cursor left or right. 

423 

424 Args: 

425 direction: "left" or "right" 

426 

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 

432 

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) 

437 

438 self._update_cursor() 

439 

440 if self.on_cursor_move: 

441 self.on_cursor_move(self.cursor_position) 

442 

443 def _update_cursor(self) -> None: 

444 """Update cursor line on plot. 

445 

446 References: 

447 ACC-003: Keyboard Navigation for Interactive Plots 

448 """ 

449 ylim = self.axes.get_ylim() 

450 

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]) 

463 

464 self.fig.canvas.draw_idle() 

465 

466 def _zoom(self, factor: float) -> None: 

467 """Zoom in or out. 

468 

469 Args: 

470 factor: Zoom factor (>1 = zoom in, <1 = zoom out) 

471 

472 References: 

473 ACC-003: Keyboard Navigation for Interactive Plots 

474 """ 

475 xlim = self.axes.get_xlim() 

476 ylim = self.axes.get_ylim() 

477 

478 x_center = (xlim[0] + xlim[1]) / 2 

479 y_center = (ylim[0] + ylim[1]) / 2 

480 

481 x_range = (xlim[1] - xlim[0]) / factor 

482 y_range = (ylim[1] - ylim[0]) / factor 

483 

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) 

486 

487 self.fig.canvas.draw_idle() 

488 

489 def _jump_to_start(self) -> None: 

490 """Jump cursor to start of plot. 

491 

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() 

498 

499 if self.on_cursor_move: 

500 self.on_cursor_move(self.cursor_position) 

501 

502 def _jump_to_end(self) -> None: 

503 """Jump cursor to end of plot. 

504 

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() 

511 

512 if self.on_cursor_move: 

513 self.on_cursor_move(self.cursor_position) 

514 

515 

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]