Skip to content
Open
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
69 changes: 69 additions & 0 deletions docs/accessibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Accessibility

Flint sits at the compile step, so accessibility surfaces can be generated
once from the semantic layer and land in every backend consistently —
designed in rather than patched on per chart.

## What is emitted by default

Every compiled chart carries a short generated description built from the
resolved semantics and the data: chart type, measure (with unit), dimension,
series, category/series counts, and the value range. The copy is structural
and statistical only — Flint never emits perceptual or interpretive claims
("X is trending up") it cannot verify.

Example, for a bar chart of `revenue` (annotated `Price` + `unit: "USD"`) by
`region`:

```
Bar Chart of revenue (USD) by region. 3 categories. Range 145–168.
```

Per backend:

| Backend | Surface |
|---|---|
| Vega-Lite | Top-level `description` (rendered as the SVG `aria-label`) |
| ECharts | `aria: { enabled: true, label: { description } }` (built-in aria module) |
| Chart.js | `_a11y.description` metadata — see wiring note below |

`field_display_names` is respected, so hosts can control the field wording
without touching the data.

## Decal patterns (opt-in)

ECharts can overlay per-series texture patterns so series remain
distinguishable without color (color-vision deficiency support):

```ts
assembleECharts(input, /* options: */) // via input.options
// { ...input, options: { a11yDecal: true } }
```

When `options.a11yDecal` is `true`, the compiled option gains
`aria.decal.show: true`. This is off by default because decals visibly change
the chart; the description metadata above is invisible and always on.

## Chart.js wiring note

Chart.js renders to a canvas, which has no native description surface. The
compiled config carries the text as `_a11y.description`; hosts should set it
on the canvas element:

```ts
const config = assembleChartjs(input);
canvas.setAttribute('role', 'img');
canvas.setAttribute('aria-label', config._a11y.description);
```

## Chartability coverage

Mapped against [Chartability](https://chartability.github.io/POUR-CAF/)
heuristics, this baseline addresses the critical "no title/summary/caption"
failure (generated descriptions) and part of "conveys meaning through visuals
alone" (aria metadata; decal for color-independent series identity on
ECharts). Not yet covered at the compile step: contrast floors on palette
selection, text-size floors, and data-density checks — candidates for
follow-up work on the existing `ChartWarning` channel. Keyboard navigation,
screen-reader interaction testing, and cognitive-load concerns live with the
host application, outside a compiler's reach.
1 change: 1 addition & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,4 @@ Inspect `_warnings` or `ChartWarning` arrays in integration code to surface trun
- [Semantic Type](/documentation/semantic-types) — type hierarchy and resolution
- [Getting started](/documentation/getting-started) — hands-on walkthrough
- [Extending backends](/documentation/adding-a-backend) — new `assemble*()` target
- [Accessibility](/documentation/accessibility) — generated descriptions, aria, decal opt-in
13 changes: 13 additions & 0 deletions packages/flint-js/src/chartjs/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
LayoutDeclaration,
InstantiateContext,
} from '../core/types';
import { buildChartDescription } from '../core/a11y-description';
import type { ChartWarning } from '../core/types';
import { applyEncodingOverrides } from '../core/encoding-overrides';
import { applyAggregation } from '../core/aggregate';
Expand Down Expand Up @@ -421,6 +422,18 @@ export function assembleChartjs(input: ChartAssemblyInput): any {

cjsConfig._dataLength = values.length;

// Accessible description metadata. Chart.js renders to a canvas with no
// native description surface, so hosts read `_a11y.description` and set it
// as the canvas aria-label (see docs/accessibility.md).
cjsConfig._a11y = {
description: buildChartDescription({
chartType,
channelSemantics,
table: values,
fieldDisplayNames: input.field_display_names,
}),
};

if (pivoted.surface) {
cjsConfig._pivot = pivoted.surface;
}
Expand Down
153 changes: 153 additions & 0 deletions packages/flint-js/src/core/a11y-description.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
* Accessible chart description generation.
*
* Builds a short, deterministic, screen-reader-first description of a chart
* from the resolved channel semantics and the data table. The copy is limited
* to structural and statistical content (what the chart is and what the data
* spans) — it never makes perceptual or interpretive claims ("X is trending
* up"), which a compiler cannot verify.
*
* Backends inject the result where their renderer exposes it:
* Vega-Lite `description`, ECharts `aria.label.description`, and a
* `_a11y.description` metadata field for Chart.js hosts.
*/

import type { ChannelSemantics } from './types';

/** Channels that carry the measure in priority order. */
const MEASURE_CHANNELS = ['y', 'x', 'size', 'color'];
/** Channels that carry the category/axis dimension in priority order. */
const DIMENSION_CHANNELS = ['x', 'y', 'column', 'row'];
/** Channels that split the data into series. */
const SERIES_CHANNELS = ['color', 'group', 'detail', 'strokeDash'];

const isDiscrete = (t: string | undefined) => t === 'nominal' || t === 'ordinal';

function displayName(
field: string,
fieldDisplayNames?: Record<string, string>,
): string {
return fieldDisplayNames?.[field] ?? field;
}

/** Compact, locale-neutral number formatting for range statements. */
function formatStat(v: number): string {
if (!Number.isFinite(v)) return String(v);
const abs = Math.abs(v);
if (abs >= 1e9) return `${trimTrailingZero(v / 1e9)}B`;
if (abs >= 1e6) return `${trimTrailingZero(v / 1e6)}M`;
if (abs >= 1e4) return `${trimTrailingZero(v / 1e3)}K`;
if (Number.isInteger(v)) return String(v);
return String(Math.round(v * 100) / 100);
}

function trimTrailingZero(v: number): string {
const r = Math.round(v * 10) / 10;
return Number.isInteger(r) ? String(r) : String(r);
}

function measureLabel(
cs: ChannelSemantics,
fieldDisplayNames?: Record<string, string>,
): string {
const name = displayName(cs.field, fieldDisplayNames);
const unit = cs.semanticAnnotation?.unit;
return unit ? `${name} (${unit})` : name;
}

/**
* Build a short accessible description for a compiled chart.
*
* Structure: "<Chart type> of <measure> by <dimension>[, grouped by <series>].
* [<n> categories.] [<n> series.] [Range <min>–<max>.]"
*/
export function buildChartDescription(args: {
chartType: string;
channelSemantics: Record<string, ChannelSemantics>;
table: Record<string, unknown>[];
fieldDisplayNames?: Record<string, string>;
}): string {
const { chartType, channelSemantics, table, fieldDisplayNames } = args;

// Resolve the primary measure (first quantitative channel by priority).
let measure: ChannelSemantics | undefined;
for (const ch of MEASURE_CHANNELS) {
const cs = channelSemantics[ch];
if (cs && cs.type === 'quantitative') { measure = cs; break; }
}

// Resolve the primary dimension (first discrete/temporal positional channel
// that isn't the measure's own channel).
let dimension: ChannelSemantics | undefined;
for (const ch of DIMENSION_CHANNELS) {
const cs = channelSemantics[ch];
if (cs && cs !== measure && (isDiscrete(cs.type) || cs.type === 'temporal')) {
dimension = cs;
break;
}
}

// Resolve the series channel (first discrete series channel distinct from
// measure and dimension).
let series: ChannelSemantics | undefined;
for (const ch of SERIES_CHANNELS) {
const cs = channelSemantics[ch];
if (cs && cs !== measure && cs !== dimension && isDiscrete(cs.type)) {
series = cs;
break;
}
}

const parts: string[] = [];

// L1 — structure.
let head = chartType;
if (measure) head += ` of ${measureLabel(measure, fieldDisplayNames)}`;
if (dimension) head += ` by ${displayName(dimension.field, fieldDisplayNames)}`;
if (series) head += `, grouped by ${displayName(series.field, fieldDisplayNames)}`;
parts.push(`${head}.`);

// L2 — cheap statistics from the table.
if (dimension && isDiscrete(dimension.type)) {
const n = cardinality(table, dimension.field);
if (n > 0) parts.push(`${n} categories.`);
}
if (series) {
const n = cardinality(table, series.field);
if (n > 1) parts.push(`${n} series.`);
}
if (measure) {
const range = valueRange(table, measure.field);
if (range) parts.push(`Range ${formatStat(range[0])}–${formatStat(range[1])}.`);
}

return parts.join(' ');
}

function cardinality(table: Record<string, unknown>[], field: string): number {
const seen = new Set<string>();
for (const row of table) {
const v = row[field];
if (v != null) seen.add(String(v));
}
return seen.size;
}

function valueRange(
table: Record<string, unknown>[],
field: string,
): [number, number] | null {
let min = Infinity;
let max = -Infinity;
for (const row of table) {
const v = Number(row[field]);
if (Number.isFinite(v)) {
if (v < min) min = v;
if (v > max) max = v;
}
}
return min <= max ? [min, max] : null;
}
8 changes: 8 additions & 0 deletions packages/flint-js/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,14 @@ export interface ChartAssemblyInput {
export interface AssembleOptions {
/** Whether to add tooltips to the chart (default: false) */
addTooltips?: boolean;
/**
* Opt in to ECharts decal patterns (`aria.decal`): per-series texture
* overlays that keep series distinguishable without color (color-vision
* deficiency support). Off by default because decals visibly change the
* chart. Accessible descriptions and aria labels are always emitted and
* need no flag. ECharts backend only (default: false).
*/
a11yDecal?: boolean;
/**
* Fraction of each step reserved for inter-category padding (0–1).
* VL pads *inside* the step (band = step × (1 − padding)), so this
Expand Down
19 changes: 19 additions & 0 deletions packages/flint-js/src/echarts/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
LayoutDeclaration,
InstantiateContext,
} from '../core/types';
import { buildChartDescription } from '../core/a11y-description';
import type { ChartWarning } from '../core/types';
import { applyEncodingOverrides } from '../core/encoding-overrides';
import { applyAggregation } from '../core/aggregate';
Expand Down Expand Up @@ -499,6 +500,24 @@ export function assembleECharts(input: ChartAssemblyInput): any {
// RESULT
// ═══════════════════════════════════════════════════════════════════════

// Accessible metadata via ECharts' built-in aria module. The generated
// label description is invisible metadata and always on; decal patterns
// visibly change the chart, so they stay behind options.a11yDecal.
if (ecOption.aria == null) {
ecOption.aria = {
enabled: true,
label: {
description: buildChartDescription({
chartType,
channelSemantics,
table: values,
fieldDisplayNames: input.field_display_names,
}),
},
...(effectiveOptions.a11yDecal ? { decal: { show: true } } : {}),
};
}

// Attach metadata
if (warnings.length > 0) {
ecOption._warnings = warnings;
Expand Down
10 changes: 10 additions & 0 deletions packages/flint-js/src/vegalite/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
LayoutDeclaration,
InstantiateContext,
} from '../core/types';
import { buildChartDescription } from '../core/a11y-description';
import type { ChartWarning, ChartOption, OptionEvalContext } from '../core/types';
import { applyEncodingOverrides } from '../core/encoding-overrides';
import { applyAggregation } from '../core/aggregate';
Expand Down Expand Up @@ -671,6 +672,15 @@ export function assembleVegaLite(input: ChartAssemblyInput): any {
if (pivoted.surface) {
result._pivot = pivoted.surface;
}

// Accessible description (structural + statistical), exposed through the
// Vega-Lite `description` property (rendered as the SVG aria-label).
if (result.description == null) {
result.description = buildChartDescription({
chartType, channelSemantics, table: data, fieldDisplayNames,
});
}

return result;
}

Expand Down
Loading