feat: add Highcharts charting library adapter#549
Conversation
Add a new `maidr/highcharts` entry point that converts rendered Highcharts chart instances into MAIDR-compatible data structures. This enables accessible non-visual access (audio sonification, text descriptions, braille output, keyboard navigation) for Highcharts visualizations. Supported chart types: - Bar / Column (with orientation detection and inverted charts) - Line / Spline / Area / Areaspline (multi-line support) - Scatter - Boxplot - Heatmap - Histogram Key components: - adapter.ts: Core `highchartsToMaidr()` conversion function - selectors.ts: CSS selector generation scoped per chart container - sync.ts: `createHighchartsSync()` for programmatic tooltip and point highlighting (bidirectional visual synchronization) - types.ts: Minimal Highcharts type definitions (no runtime dependency) Also includes a Vite build config, package.json exports for `maidr/highcharts`, and an example HTML page demonstrating integration. Closes #539 https://claude.ai/code/session_01UYVmQa6fa2p7vC8LrbbYpX
PR Review: feat: add Highcharts charting library adapterThis is a well-structured addition that fills an important gap — enabling MAIDR accessibility features for Highcharts visualizations. The overall design is clean and the type definitions are sensibly minimal. Here are my findings: Bugs / Correctness Issues1. Null safety in for (const p of series.data) {
if (p.y !== null) {
const xIdx = Math.round(p.x);
const yIdx = Math.round(p.y); // p.y typed as number | nullTypeScript still considers 2. Unreliable histogram bin width fallback (adapter.ts ~L350) const x2 = typeof opts.x2 === 'number' ? opts.x2 : p.x + 1;Using 3. function filterSeries(chart, indices?) {
const all = indices ? indices.map(i => chart.series[i]).filter(Boolean) : chart.series;
return all.filter(s => s.visible); // silently discards invisible series
}If a caller passes 4. Wrong fallback value in heatmap cell (adapter.ts ~L302) const value = p.options?.value;
points[yIdx][xIdx] = typeof value === 'number' ? value : (p.y as number);
Architecture / Design Concerns5. The Without a clear integration path, this API may go unused. Consider documenting a concrete wiring example, or adding a navigation event hook in MAIDR's Observer flow that external adapters can subscribe to. 6. Multi-line layer loses series metadata return {
id: String(first.index), // only first series index
title: first.name || undefined, // only first series name
...When multiple line series are merged into a single 7. Legend count can mismatch layer count if (seriesToConvert.length > 1) {
subplot.legend = seriesToConvert.map(s => s.name);
}
Code Quality8. window._sync = sync; // Expose for debugging.This should be removed before merging. 9. Re-initialization step missing in example The example sets 10. Module-level counters ( Both counters work fine at runtime, but in test environments where modules may be reset they could produce non-deterministic IDs. Consider exporting a reset function for testing, or seeding via the options object. Missing TestsThere are no unit or integration tests for any of the new adapter code. Given the data transformation logic (especially heatmap grid construction and histogram bin extraction), test coverage is important. At minimum, tests for:
Minor Nits
Summary
The core idea and structure are solid. The most important items to address before merging are: the broken example HTML (step 4), the heatmap fallback value bug, the silent |
PR review fixes: - Fix heatmap value extraction: use `point.options.value` instead of falling back to `p.y` (which is the y-axis index, not the cell value) - Fix heatmap null safety: guard `p.y === null` before using as index, infer grid dimensions from data when no axis categories exist - Fix histogram bin width: use `point.options.x` and `point.options.x2` for actual bin boundaries instead of assuming unit-width bins - Fix filterSeries: warn via console when explicit indices are hidden or out of range instead of silently dropping them - Fix legend/layer alignment: legend array now matches layers (not input series) to prevent downstream mismatch - Fix multi-line layer metadata: use combined title from all series names and a composite ID instead of only the first series - Add `resetChartCounter()` and `resetSelectorCounter()` for deterministic test output - Fix example: remove `window._sync` debug assignment, fix load order so MAIDR script runs after `maidr-data` attribute is set - Improve HighchartsSync docs: add concrete MutationObserver integration example showing how to wire MAIDR navigation to Highcharts tooltips - Log warning for unsupported series types instead of silent skip New chart type support (all MAIDR trace types now covered): - Candlestick/OHLC → TraceType.CANDLESTICK (with trend/volatility) - Stacked bar/column → TraceType.STACKED (stacking: 'normal') - Dodged (grouped) bar/column → TraceType.DODGED (no stacking) - Normalized stacked bar → TraceType.NORMALIZED (stacking: 'percent') - Smooth/spline → TraceType.SMOOTH (with SVG coordinate extraction) - Added corresponding CSS selectors for all new types https://claude.ai/code/session_01UYVmQa6fa2p7vC8LrbbYpX
PR Review: Highcharts AdapterGreat addition! The overall structure is clean and the use of custom type definitions to avoid a hard dependency on the Highcharts package is the right approach. Here are some findings across quality, correctness, and design: Bugs / Correctness1. const bbox = graphic.getBoundingClientRect();
svgX = bbox.x + bbox.width / 2;
svgY = bbox.y + bbox.height / 2;
2. Boxplot silently defaults missing statistics to min: p.low ?? 0,
q1: p.q1 ?? 0,
q2: p.median ?? 0,
q3: p.q3 ?? 0,
max: p.high ?? 0,Defaulting to 3. Both functions return Design / Architecture4. Spline/areaspline series enter 5. Stacking mode detection reads only the first series (adapter.ts:347–348) const first = barSeries[0];
const stacking = getStackingMode(first, chart);If series have mixed or inconsistent stacking settings, only the first series determines the layout type for all of them. Consider checking that all series agree on the stacking mode (or at least that all visible ones match) and logging a warning if they diverge. 6. Assigning API Surface7. Test-only utilities exported as public API
Test Coverage8. No unit tests included The project has a test suite ( Minor
Overall this is solid foundational work. The type definitions, build config, and conversion structure are well-organized. Addressing the coordinate-space bug in smooth traces and the boxplot silent-zero issue would be the highest-priority fixes before merging. |
- Remove convertSmoothSeries (dead code with broken getBoundingClientRect viewport coords) and smoothSelectors (duplicate of lineSelectors) - Remove resetChartCounter and resetSelectorCounter from public API (test utilities should not be exported to consumers) - Add warnings for missing boxplot statistics instead of silently defaulting to 0 - Check stacking mode across all bar series for consistency, not just the first series - Document ensureContainerId DOM mutation side effect https://claude.ai/code/session_01UYVmQa6fa2p7vC8LrbbYpX
Code Review: Highcharts AdapterGreat addition! The overall architecture is clean — a standalone adapter with no runtime Highcharts dependency is exactly the right approach. The separation into Here are my findings, roughly in order of severity: Bugs1. Module-level counter inconsistency
2. When 3. Histogram bin width degrades to zero In 4. Undefined Several converters use 5. Heatmap value fallback silently zero-fills In Code Quality6. In 7. Both generate the same selector pattern ( 8. Fragile implementation detail in The Option 1 example hardcodes 9. Legend assignment uses first series title for grouped bar layers subplot.legend = layers.map(l => l.title ?? `Series ${l.id}`);For stacked/dodged layers, Test CoverageNo tests are included for any of the new files. The
Minor Observations
Overall this is solid work. The type definitions, JSDoc, and adapter logic are well thought-out. Addressing the null/undefined handling, the histogram bin-width bug, and adding a minimal test suite would make this ready to merge. |
There was a problem hiding this comment.
Pull request overview
Adds a new maidr/highcharts adapter entry point that converts rendered Highcharts chart instances into MAIDR-compatible data structures, enabling sonification, text descriptions, braille output, and keyboard navigation for Highcharts visualizations. Includes a small <Maidr> pointer-down focus shim and model-layer support for per-cell heatmap selector grids and path-encoded scatter coordinates.
Changes:
- New
src/adapters/highcharts/{adapter,selectors,sync,types,index}.tsprovidinghighchartsToMaidr()andcreateHighchartsSync(), plus DOM attribute stamping and SVG<path>splitting for box/candlestick highlight subparts. - Heatmap model now accepts
string[][]per-cell selector grids;ScatterTracefalls back to parsingd="M x y …";MaidrLayer.selectorswidened to includestring[][];<Maidr>wrapper gains a click-to-focus handler. - Build/site/docs wiring: new Vite build target,
package.jsonexport, docs page, sitemap entry, ten example HTML files, and template menu link.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/adapters/highcharts/adapter.ts | Core Highcharts→MAIDR conversion with stacking/orientation detection and SVG stamping/splitting. |
| src/adapters/highcharts/selectors.ts | Per-chart scoped selector generators including 2D heatmap grid and box/candle subpart selectors. |
| src/adapters/highcharts/sync.ts | createHighchartsSync() hover/tooltip controller. |
| src/adapters/highcharts/types.ts | Minimal structural Highcharts type definitions (no runtime dep). |
| src/adapters/highcharts/index.ts | Public re-exports for maidr/highcharts. |
| src/model/heatmap.ts | Adds string[][] selector grid branch alongside existing string selector. |
| src/model/scatter.ts | Fallback marker-center extraction from path d attribute. |
| src/type/grammar.ts | Widens MaidrLayer.selectors to permit string[][]. |
| src/maidr-component.tsx | Pointer-down handler to focus the wrapper when inert SVG children are clicked. |
| package.json | Adds ./highcharts export. |
| scripts/build.js | Adds Vite build config for the Highcharts entry. |
| scripts/build-site.js | Adds docs page, examples list, sitemap, and template active-state for Highcharts. |
| docs/template.html | Adds Highcharts menu link. |
| docs/highcharts.md | Full integration guide and API reference. |
| examples/highcharts-*.html | 10 demo pages covering supported chart types. |
| ### Visual sync lifecycle | ||
|
|
||
| `createHighchartsSync()` attaches event listeners to the chart container. Always call `sync.dispose()` if you destroy the chart manually (otherwise listeners leak): | ||
|
|
||
| ```js | ||
| const sync = maidrHighcharts.createHighchartsSync(chart); | ||
|
|
||
| // later: | ||
| chart.destroy(); | ||
| sync.dispose(); | ||
| ``` |
|
|
||
| // Highcharts emits each candle as a `<path class="highcharts-point">` | ||
| // directly under the series group (no wrapping `<g>` like boxplot). | ||
| const selector = `.highcharts-series-group .highcharts-series-${seriesIndex} path.highcharts-point`; | ||
| const paths = container.querySelectorAll<SVGPathElement>(selector); | ||
|
|
||
| if (paths.length !== expectedCount) { | ||
| console.warn( | ||
| `[MAIDR Highcharts] Candlestick series ${seriesIndex}: expected ${expectedCount} ` | ||
| + `candle paths but found ${paths.length} in DOM. Highlight may not work.`, | ||
| ); | ||
| } | ||
|
|
||
| paths.forEach((path, i) => { | ||
| path.removeAttribute('data-maidr-candle-index'); | ||
| path.setAttribute('data-maidr-candle-index', String(i)); | ||
| splitCandlestickPath(path, i); | ||
| }); | ||
| } |
| function convertBoxSeries( | ||
| series: HighchartsSeries, | ||
| chart: HighchartsChart, | ||
| containerId: string, | ||
| ): MaidrLayer { |
| } | ||
|
|
||
| function pointLabel(point: HighchartsPoint): string | number { | ||
| return point.category ?? point.name ?? point.x; |
| export function createHighchartsSync(chart: HighchartsChart): HighchartsSync { | ||
| let activePoint: HighchartsPoint | null = null; | ||
|
|
||
| function highlightPoint(seriesIndex: number, pointIndex: number): void { | ||
| const series = chart.series[seriesIndex]; | ||
| if (!series) | ||
| return; | ||
|
|
||
| const point = series.data[pointIndex]; | ||
| if (!point) | ||
| return; | ||
|
|
||
| // Clear previous highlight. | ||
| if (activePoint && activePoint !== point) { | ||
| activePoint.setState?.(''); | ||
| } | ||
|
|
||
| // Highlight the new point. | ||
| point.setState?.('hover'); | ||
| chart.tooltip?.refresh(point); | ||
| activePoint = point; | ||
| } |
| export function highchartsToMaidr( | ||
| chart: HighchartsChart, | ||
| options?: HighchartsAdapterOptions, | ||
| ): Maidr { | ||
| const id = options?.id ?? `highcharts-${chartCounter++}`; | ||
| const title = options?.title ?? chart.title?.textStr ?? ''; | ||
| const subtitle = chart.subtitle?.textStr; | ||
| const caption = chart.caption?.textStr; | ||
|
|
||
| const containerId = ensureContainerId(chart); | ||
|
|
||
| const seriesToConvert = filterSeries(chart, options?.seriesIndices); | ||
|
|
||
| // Categorize series by how they need to be converted. | ||
| const lineTypes = new Set(['line', 'spline', 'area', 'areaspline']); | ||
| const barTypes = new Set(['bar', 'column']); | ||
|
|
||
| const lineSeries = seriesToConvert.filter(s => lineTypes.has(resolveSeriesType(s, chart))); | ||
| const barSeries = seriesToConvert.filter(s => barTypes.has(resolveSeriesType(s, chart))); | ||
| const otherSeries = seriesToConvert.filter( | ||
| s => !lineTypes.has(resolveSeriesType(s, chart)) && !barTypes.has(resolveSeriesType(s, chart)), | ||
| ); | ||
|
|
||
| const layers: MaidrLayer[] = []; | ||
|
|
||
| // Convert bar/column series — may be stacked, dodged, or normalized. | ||
| if (barSeries.length > 0) { | ||
| layers.push(...convertBarGroup(barSeries, chart, containerId)); | ||
| } | ||
|
|
||
| // Convert non-line/non-bar series individually. | ||
| for (const series of otherSeries) { | ||
| const layer = convertSeries(series, chart, containerId); | ||
| if (layer) { | ||
| layers.push(layer); | ||
| } | ||
| } | ||
|
|
||
| // Convert line series together as a single multi-line layer (MAIDR expects LinePoint[][]). | ||
| if (lineSeries.length > 0) { | ||
| const layer = convertLineSeries(lineSeries, chart, containerId); | ||
| if (layer) { | ||
| layers.push(layer); | ||
| } | ||
| } | ||
|
|
||
| const subplot: MaidrSubplot = { layers }; | ||
|
|
||
| // Add legend labels when multiple layers are present, aligned to layers. | ||
| if (layers.length > 1) { | ||
| subplot.legend = layers.map(l => l.title ?? `Series ${l.id}`); | ||
| } | ||
|
|
||
| return { | ||
| id, | ||
| title, | ||
| subtitle, | ||
| caption, | ||
| subplots: [[subplot]], | ||
| }; | ||
| } |
|
🎉 This PR is included in version 3.69.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Add a new
maidr/highchartsentry point that converts rendered Highchartschart instances into MAIDR-compatible data structures. This enables
accessible non-visual access (audio sonification, text descriptions,
braille output, keyboard navigation) for Highcharts visualizations.
Supported chart types:
Key components:
highchartsToMaidr()conversion functioncreateHighchartsSync()for programmatic tooltip andpoint highlighting (bidirectional visual synchronization)
Also includes a Vite build config, package.json exports for
maidr/highcharts, and an example HTML page demonstrating integration.Closes #539
https://claude.ai/code/session_01UYVmQa6fa2p7vC8LrbbYpX