Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mcp/src/__snapshots__/chartFeatures.test.ts.snap

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions mcp/src/__snapshots__/charts.test.ts.snap

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion mcp/src/__snapshots__/orchestrationTools.test.ts.snap

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/components/AreaChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getPlotArea } from '../core/geometry';
import { colorAt } from '../core/palette';
import { resolveBrand } from '../brand/resolveBrand';
import { seriesTable } from '../core/dataTable';
import { describeSeries } from '../core/a11yDescribe';
import { layoutLegend } from '../core/legend';
import type { LegendItem } from '../core/legend';
import { resolveVibe } from '../vibe/resolveVibe';
Expand Down Expand Up @@ -173,7 +174,7 @@ export function AreaChart({
vibe={vibe}
brand={brand}
title={title}
description={description}
description={description ?? describeSeries(series, 'Area')}
ariaLabel={ariaLabel}
dataTable={dataTable ? seriesTable(series, title) : undefined}
className={className}
Expand Down
3 changes: 2 additions & 1 deletion src/components/BarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getPlotArea } from '../core/geometry';
import { colorAt } from '../core/palette';
import { resolveBrand } from '../brand/resolveBrand';
import { datumTable } from '../core/dataTable';
import { describeBars } from '../core/a11yDescribe';
import { groupMax, seriesKeysOf, stackLayout, stackMax } from '../core/stack';
import { Surface } from './Surface';
import { Axis } from './Axis';
Expand Down Expand Up @@ -215,7 +216,7 @@ export function BarChart({
vibe={vibe}
brand={brand}
title={title}
description={description}
description={description ?? describeBars(data)}
ariaLabel={ariaLabel}
dataTable={table}
className={className}
Expand Down
3 changes: 2 additions & 1 deletion src/components/LineChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getPlotArea } from '../core/geometry';
import { colorAt } from '../core/palette';
import { resolveBrand } from '../brand/resolveBrand';
import { seriesTable } from '../core/dataTable';
import { describeSeries } from '../core/a11yDescribe';
import { layoutLegend } from '../core/legend';
import type { LegendItem } from '../core/legend';
import { resolveVibe } from '../vibe/resolveVibe';
Expand Down Expand Up @@ -123,7 +124,7 @@ export function LineChart({
vibe={vibe}
brand={brand}
title={title}
description={description}
description={description ?? describeSeries(series, 'Line')}
ariaLabel={ariaLabel}
dataTable={dataTable ? seriesTable(series, title) : undefined}
className={className}
Expand Down
3 changes: 2 additions & 1 deletion src/components/PieChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { computePie } from '../core/arc';
import { colorAt } from '../core/palette';
import { resolveBrand } from '../brand/resolveBrand';
import { datumTable } from '../core/dataTable';
import { describePie } from '../core/a11yDescribe';
import { resolveVibe } from '../vibe/resolveVibe';
import { Surface } from './Surface';
import { RoughPath } from '../primitives/RoughPath';
Expand Down Expand Up @@ -62,7 +63,7 @@ export function PieChart({
vibe={vibe}
brand={brand}
title={title}
description={description}
description={description ?? describePie(data)}
ariaLabel={ariaLabel}
dataTable={dataTable ? datumTable(data, title) : undefined}
className={className}
Expand Down
3 changes: 2 additions & 1 deletion src/components/ScatterPlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { resolveEmphasis } from '../core/emphasis';
import { RoughCircle } from '../primitives/RoughCircle';
import { useVibeContext } from '../vibe/VibeProvider';
import { markAttrs } from '../core/interaction';
import { describeScatter } from '../core/a11yDescribe';

export interface ScatterDatum {
x: number;
Expand Down Expand Up @@ -103,7 +104,7 @@ export function ScatterPlot({
vibe={vibe}
brand={brand}
title={title}
description={description}
description={description ?? describeScatter(data)}
ariaLabel={ariaLabel}
dataTable={
dataTable
Expand Down
120 changes: 120 additions & 0 deletions src/components/a11y.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { BarChart } from './BarChart';
import { LineChart } from './LineChart';
import { AreaChart } from './AreaChart';
import { PieChart } from './PieChart';
import { ScatterPlot } from './ScatterPlot';

const BAR_DATA = [
{ label: 'Q1', value: 12 },
{ label: 'Q2', value: 19 },
];
const SERIES_DATA = [
{
id: 'a',
points: [
{ x: 0, y: 1 },
{ x: 1, y: 5 },
],
},
];
const PIE_DATA = [
{ label: 'A', value: 10 },
{ label: 'B', value: 20 },
];
const SCATTER_DATA = [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
];

const render = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);

describe('a11y: chart SVGs expose role/title/desc', () => {
it('BarChart emits role=img and a fallback <desc> from data when description is omitted', () => {
const svg = render(
createElement(BarChart, { width: 200, height: 100, data: BAR_DATA, bare: true } as any),

Check warning on line 37 in src/components/a11y.test.ts

View workflow job for this annotation

GitHub Actions / library

Unexpected any. Specify a different type
);
expect(svg).toContain('role="img"');
expect(svg).toMatch(/<desc>Bar chart with 2 categories, values from 12 to 19\.<\/desc>/);
});

it('BarChart respects an explicit description over the fallback', () => {
const svg = render(
createElement(BarChart, {
width: 200,
height: 100,
data: BAR_DATA,
title: 'Sales',
description: 'Quarterly sales for FY26.',
bare: true,
} as any),

Check warning on line 52 in src/components/a11y.test.ts

View workflow job for this annotation

GitHub Actions / library

Unexpected any. Specify a different type
);
expect(svg).toContain('<title>Sales</title>');
expect(svg).toContain('<desc>Quarterly sales for FY26.</desc>');
});

it('LineChart emits a series-aware fallback description', () => {
const svg = render(
createElement(LineChart, {
width: 200,
height: 100,
series: SERIES_DATA,
bare: true,
} as any),

Check warning on line 65 in src/components/a11y.test.ts

View workflow job for this annotation

GitHub Actions / library

Unexpected any. Specify a different type
);
expect(svg).toMatch(
/<desc>Line chart with 1 series and 2 points, y values from 1 to 5\.<\/desc>/,
);
});

it('AreaChart fallback labels itself as Area', () => {
const svg = render(
createElement(AreaChart, {
width: 200,
height: 100,
series: SERIES_DATA,
bare: true,
} as any),

Check warning on line 79 in src/components/a11y.test.ts

View workflow job for this annotation

GitHub Actions / library

Unexpected any. Specify a different type
);
expect(svg).toMatch(/<desc>Area chart with /);
});

it('PieChart fallback describes slice count and total', () => {
const svg = render(
createElement(PieChart, {
width: 200,
height: 200,
data: PIE_DATA,
bare: true,
} as any),
);
expect(svg).toContain('<desc>Pie chart with 2 slices totaling 30.</desc>');
});

it('ScatterPlot fallback describes point count', () => {
const svg = render(
createElement(ScatterPlot, {
width: 200,
height: 100,
data: SCATTER_DATA,
bare: true,
} as any),
);
expect(svg).toContain('<desc>Scatter plot with 2 points.</desc>');
});

it('aria-label falls back to title when ariaLabel is omitted', () => {
const svg = render(
createElement(BarChart, {
width: 200,
height: 100,
data: BAR_DATA,
title: 'Revenue',
bare: true,
} as any),
);
expect(svg).toContain('aria-label="Revenue"');
});
});
92 changes: 92 additions & 0 deletions src/core/a11yDescribe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { describeBars, describePie, describeScatter, describeSeries } from './a11yDescribe';

describe('a11yDescribe', () => {
it('describes single-series bars with category count and value range', () => {
expect(
describeBars([
{ label: 'Q1', value: 12 },
{ label: 'Q2', value: 19 },
{ label: 'Q3', value: 7 },
{ label: 'Q4', value: 24 },
]),
).toBe('Bar chart with 4 categories, values from 7 to 24.');
});

it('handles a single category (singular noun)', () => {
expect(describeBars([{ label: 'Only', value: 5 }])).toBe('Bar chart with 1 category, value 5.');
});

it('describes multi-series bars with series count', () => {
expect(
describeBars([
{ label: 'Q1', values: { a: 1, b: 2 } },
{ label: 'Q2', values: { a: 3, b: 4 } },
]),
).toBe('Bar chart with 2 categories across 2 series, values from 1 to 4.');
});

it('returns a usable string for empty bars', () => {
expect(describeBars([])).toBe('Bar chart with no data.');
});

it('describes line/area series with total point count', () => {
expect(
describeSeries(
[
{
id: 'a',
points: [
{ x: 0, y: 1 },
{ x: 1, y: 5 },
],
},
{
id: 'b',
points: [
{ x: 0, y: -3 },
{ x: 1, y: 2 },
],
},
],
'Line',
),
).toBe('Line chart with 2 series and 4 points, y values from -3 to 5.');
});

it('respects the Area kind label', () => {
expect(describeSeries([{ id: 'a', points: [{ x: 0, y: 1 }] }], 'Area')).toBe(
'Area chart with 1 series and 1 point, y value 1.',
);
});

it('describes pies with slice count and total', () => {
expect(
describePie([
{ label: 'A', value: 30 },
{ label: 'B', value: 20 },
{ label: 'C', value: 10 },
]),
).toBe('Pie chart with 3 slices totaling 60.');
});

it('formats non-integer values with up to two decimals, trimmed', () => {
expect(describePie([{ label: 'A', value: 1.5 }])).toBe('Pie chart with 1 slice totaling 1.5.');
});

it('describes scatter data with point count', () => {
expect(
describeScatter([
{ x: 0, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 4 },
]),
).toBe('Scatter plot with 3 points.');
});

it('handles empty inputs across all generators', () => {
expect(describeSeries([])).toBe('Line chart with no data.');
expect(describePie([])).toBe('Pie chart with no data.');
expect(describeScatter([])).toBe('Scatter plot with no data.');
});
});
76 changes: 76 additions & 0 deletions src/core/a11yDescribe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { ChartDatum, MultiSeriesDatum, Series } from '../types/charts';

/**
* Fallback `<desc>` text generators. Each returns a short sentence summarising
* the chart's shape so screen-reader users get *something* useful when a
* consumer doesn't supply an explicit `description`. Kept deterministic and
* dependency-free; charts call these in the same expression that passes
* `description` to `<Surface>`.
*/

function isMultiSeries(data: ChartDatum[] | MultiSeriesDatum[]): data is MultiSeriesDatum[] {
return data.length > 0 && 'values' in (data[0] as object);
}

function fmt(n: number): string {
if (!Number.isFinite(n)) return String(n);
if (Number.isInteger(n)) return String(n);
return n.toFixed(2).replace(/\.?0+$/, '');
}

function rangeOf(values: number[]): string {
if (values.length === 0) return '';
let min = values[0];
let max = values[0];
for (const v of values) {
if (v < min) min = v;
if (v > max) max = v;
}
return min === max ? `value ${fmt(min)}` : `values from ${fmt(min)} to ${fmt(max)}`;
}

export function describeBars(data: ChartDatum[] | MultiSeriesDatum[]): string {
if (data.length === 0) return 'Bar chart with no data.';
if (isMultiSeries(data)) {
const seriesKeys = new Set<string>();
const allValues: number[] = [];
for (const d of data) {
for (const k of Object.keys(d.values)) seriesKeys.add(k);
for (const v of Object.values(d.values)) allValues.push(v);
}
return (
`Bar chart with ${data.length} categor${data.length === 1 ? 'y' : 'ies'} ` +
`across ${seriesKeys.size} series, ${rangeOf(allValues)}.`
);
}
const single = data as ChartDatum[];
return (
`Bar chart with ${single.length} categor${single.length === 1 ? 'y' : 'ies'}, ` +
`${rangeOf(single.map((d) => d.value))}.`
);
}

export function describeSeries(series: Series[], kind: 'Line' | 'Area' = 'Line'): string {
if (series.length === 0) return `${kind} chart with no data.`;
const total = series.reduce((s, d) => s + d.points.length, 0);
const ys = series.flatMap((s) => s.points.map((p) => p.y));
return (
`${kind} chart with ${series.length} series and ${total} ` +
`point${total === 1 ? '' : 's'}, y ${rangeOf(ys)}.`
);
}

export function describePie(data: ChartDatum[]): string {
if (data.length === 0) return 'Pie chart with no data.';
const total = data.reduce((s, d) => s + d.value, 0);
return (
`Pie chart with ${data.length} slice${data.length === 1 ? '' : 's'} ` +
`totaling ${fmt(total)}.`
);
}

/** Scatter takes a flat point array (no series wrapper); describes count only. */
export function describeScatter(data: { x: number; y: number }[]): string {
if (data.length === 0) return 'Scatter plot with no data.';
return `Scatter plot with ${data.length} point${data.length === 1 ? '' : 's'}.`;
}
Loading