diff --git a/examples/anychart-bindable.html b/examples/anychart-bindable.html
new file mode 100644
index 00000000..029fe101
--- /dev/null
+++ b/examples/anychart-bindable.html
@@ -0,0 +1,66 @@
+
+
+
+
+ maidr + AnyChart Example
+
+
+
+
+
+
+
+
+
+
+
+ maidr + AnyChart Integration
+
+ This example shows how to use the maidr/anychart adapter
+ to make an AnyChart bar chart accessible via audio sonification, text
+ descriptions, braille output, and keyboard navigation.
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 02c70fcd..6acfdcce 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "maidr",
- "version": "3.50.0",
+ "version": "3.51.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "maidr",
- "version": "3.50.0",
+ "version": "3.51.0",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
diff --git a/package.json b/package.json
index ceb2b908..5ce35441 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,11 @@
"types": "./dist/react.d.mts",
"import": "./dist/react.mjs",
"default": "./dist/react.mjs"
+ },
+ "./anychart": {
+ "types": "./dist/anychart.d.mts",
+ "import": "./dist/anychart.mjs",
+ "default": "./dist/anychart.mjs"
}
},
"main": "dist/maidr.js",
@@ -20,9 +25,10 @@
"dist"
],
"scripts": {
- "build": "vite build && vite build --config vite.react.config.ts",
+ "build": "vite build && vite build --config vite.react.config.ts && vite build --config vite.anychart.config.ts",
"build:script": "vite build",
"build:react": "vite build --config vite.react.config.ts",
+ "build:anychart": "vite build --config vite.anychart.config.ts",
"prepublishOnly": "npm run build",
"prepare": "husky",
"commitlint": "commitlint --from=HEAD~1 --to=HEAD",
diff --git a/src/adapter/anychart.ts b/src/adapter/anychart.ts
new file mode 100644
index 00000000..f7f33773
--- /dev/null
+++ b/src/adapter/anychart.ts
@@ -0,0 +1,693 @@
+/**
+ * AnyChart → MAIDR adapter.
+ *
+ * Extracts data from an AnyChart chart instance and produces a {@link Maidr}
+ * JSON object that the core MAIDR library can consume. This allows AnyChart
+ * visualizations to be made accessible via audio sonification, text
+ * descriptions, braille output, and keyboard navigation.
+ *
+ * @example
+ * ```ts
+ * import { bindAnyChart } from 'maidr/anychart';
+ *
+ * const chart = anychart.bar([4, 2, 7, 1]);
+ * chart.container('container').draw();
+ *
+ * bindAnyChart(chart);
+ * ```
+ *
+ * @packageDocumentation
+ */
+
+import type {
+ AnyChartBinderOptions,
+ AnyChartInstance,
+ AnyChartIterator,
+ AnyChartSeries,
+ AnyChartTitle,
+} from '../type/anychart';
+import type {
+ BarPoint,
+ BoxPoint,
+ CandlestickPoint,
+ CandlestickTrend,
+ HeatmapData,
+ HistogramPoint,
+ LinePoint,
+ Maidr,
+ MaidrLayer,
+ MaidrSubplot,
+ ScatterPoint,
+ SegmentedPoint,
+ SmoothPoint,
+} from '../type/grammar';
+import { TraceType } from '../type/grammar';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * AnyChart series types that are visually different from their MAIDR
+ * representation (e.g. filled area rendered as a line trace). A runtime
+ * warning is emitted for these so developers are aware of the semantic
+ * difference their screen-reader users will experience.
+ */
+const AREA_TYPES = new Set(['area', 'step-area', 'spline-area']);
+
+/**
+ * Map AnyChart series type strings to MAIDR TraceType values.
+ *
+ * AnyChart uses lowercase type names such as "bar", "line", "column", etc.
+ * Multi-word types are normalised to kebab-case before lookup.
+ * This mapping covers the chart types that MAIDR currently supports.
+ *
+ * @remarks
+ * - `"bar"` (horizontal) and `"column"` (vertical) both map to
+ * {@link TraceType.BAR}. MAIDR does not currently distinguish
+ * bar orientation at the trace-type level.
+ * - Area-family types (`area`, `step-area`, `spline-area`) map to
+ * {@link TraceType.LINE}. The fill is lost in the conversion; a
+ * runtime warning is emitted so developers are aware.
+ */
+export function mapSeriesType(anyChartType: string): TraceType | null {
+ const normalized = anyChartType.toLowerCase().replace(/[_\s]/g, '-');
+ const mapping: Record = {
+ // Both horizontal bar and vertical column map to BAR.
+ // MAIDR does not currently distinguish bar orientation at the trace level.
+ 'bar': TraceType.BAR,
+ 'column': TraceType.BAR,
+ 'line': TraceType.LINE,
+ 'spline': TraceType.LINE,
+ 'step-line': TraceType.LINE,
+ // Area types are represented as LINE; the fill is lost.
+ 'area': TraceType.LINE,
+ 'step-area': TraceType.LINE,
+ 'spline-area': TraceType.LINE,
+ 'scatter': TraceType.SCATTER,
+ 'marker': TraceType.SCATTER,
+ 'bubble': TraceType.SCATTER,
+ 'box': TraceType.BOX,
+ 'heatmap': TraceType.HEATMAP,
+ 'heat': TraceType.HEATMAP,
+ 'candlestick': TraceType.CANDLESTICK,
+ 'ohlc': TraceType.CANDLESTICK,
+ };
+
+ const traceType = mapping[normalized] ?? null;
+
+ // Warn when an area series is silently downgraded to a line trace.
+ if (traceType === TraceType.LINE && AREA_TYPES.has(normalized)) {
+ console.warn(
+ `[maidr/anychart] AnyChart "${anyChartType}" series mapped to LINE trace. `
+ + 'The filled-area visual will be represented as a line for accessibility.',
+ );
+ }
+
+ return traceType;
+}
+
+/** Safely extract the title text from an AnyChart chart. */
+function extractTitle(chart: AnyChartInstance): string | undefined {
+ try {
+ const title = chart.title();
+ if (typeof title === 'string')
+ return title;
+ return (title as AnyChartTitle).text?.() ?? undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** Safely extract the axis title text from an AnyChart Cartesian chart. */
+function extractAxisTitle(
+ chart: AnyChartInstance,
+ axis: 'x' | 'y',
+): string | undefined {
+ try {
+ const axisAccessor = axis === 'x' ? chart.xAxis : chart.yAxis;
+ if (!axisAccessor)
+ return undefined;
+ const axisObj = axisAccessor.call(chart, 0);
+ return axisObj?.title().text() ?? undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** Resolve the DOM element that holds the AnyChart SVG rendering. */
+export function resolveContainerElement(
+ chart: AnyChartInstance,
+): HTMLElement | null {
+ try {
+ const container = chart.container();
+ if (!container)
+ return null;
+
+ // container() may return a string (element id), an HTMLElement, or a
+ // Stage wrapper with its own `.container()` / `.domElement()`.
+ if (typeof container === 'string') {
+ return document.getElementById(container);
+ }
+ if (container instanceof HTMLElement) {
+ return container;
+ }
+
+ // Stage-like object
+ const stage = container as { container?: () => HTMLElement | null; domElement?: () => HTMLElement | null };
+ if (typeof stage.domElement === 'function') {
+ return stage.domElement();
+ }
+ if (typeof stage.container === 'function') {
+ const inner = stage.container();
+ if (inner instanceof HTMLElement)
+ return inner;
+ }
+ } catch {
+ // fall through
+ }
+ return null;
+}
+
+/**
+ * Extract raw data rows from an AnyChart series using its iterator.
+ *
+ * Returns an array of field maps. The concrete field names depend on the
+ * AnyChart series type – most Cartesian series expose `"x"` and `"value"`.
+ * Box series expose `"lowest"`, `"q1"`, `"median"`, `"q3"`, `"highest"`.
+ * Candlestick/OHLC series expose `"open"`, `"high"`, `"low"`, `"close"`.
+ */
+export function extractRawRows(
+ series: AnyChartSeries,
+): Array> {
+ const rows: Array> = [];
+ const iterator: AnyChartIterator = series.getIterator();
+ iterator.reset();
+ while (iterator.advance()) {
+ const row: Record = { _index: iterator.getIndex() };
+ for (const field of [
+ 'x',
+ 'name',
+ 'value',
+ 'y',
+ // Box fields
+ 'lowest',
+ 'q1',
+ 'median',
+ 'q3',
+ 'highest',
+ // Candlestick/OHLC fields
+ 'open',
+ 'high',
+ 'low',
+ 'close',
+ 'volume',
+ // Grouping
+ 'fill',
+ 'group',
+ ]) {
+ const v = iterator.get(field);
+ if (v !== undefined && v !== null)
+ row[field] = v;
+ }
+ rows.push(row);
+ }
+ return rows;
+}
+
+/**
+ * Resolve the CSS selector for a specific series index.
+ *
+ * AnyChart's internal SVG structure does not use stable, predictable class
+ * names. Consumers should supply explicit selectors via `options.selectors`
+ * for reliable highlighting. When no selectors are provided, no selector is
+ * emitted (highlighting will be unavailable).
+ */
+function resolveSelector(
+ seriesIndex: number,
+ options?: AnyChartBinderOptions,
+): string | string[] | undefined {
+ if (!options?.selectors || options.selectors.length === 0)
+ return undefined;
+
+ // If the array has exactly one element and it is a string, apply it to all
+ // series as a shared selector.
+ if (options.selectors.length === 1 && typeof options.selectors[0] === 'string')
+ return options.selectors[0];
+
+ // Per-series: look up by index.
+ return options.selectors[seriesIndex] ?? undefined;
+}
+
+function asNumber(v: unknown, fallback = 0): number {
+ if (typeof v === 'number')
+ return v;
+ const n = Number(v);
+ return Number.isNaN(n) ? fallback : n;
+}
+
+function asString(v: unknown, fallback = ''): string {
+ return v != null ? String(v) : fallback;
+}
+
+// ---------------------------------------------------------------------------
+// Layer builders – one per MAIDR trace type
+// ---------------------------------------------------------------------------
+
+function buildBarLayer(
+ series: AnyChartSeries,
+ seriesIndex: number,
+ selectors: string | string[] | undefined,
+): MaidrLayer {
+ const rows = extractRawRows(series);
+ const data: BarPoint[] = rows.map(r => ({
+ x: asString(r.x ?? r.name ?? r._index),
+ y: asNumber(r.value ?? r.y),
+ }));
+ return {
+ id: String(seriesIndex),
+ type: TraceType.BAR,
+ ...(selectors ? { selectors } : {}),
+ data,
+ };
+}
+
+function buildLineLayer(
+ series: AnyChartSeries,
+ seriesIndex: number,
+ selectors: string | string[] | undefined,
+): MaidrLayer {
+ const rows = extractRawRows(series);
+ const points: LinePoint[] = rows.map(r => ({
+ x: r.x !== undefined ? (typeof r.x === 'number' ? r.x : String(r.x)) : asNumber(r._index),
+ y: asNumber(r.value ?? r.y),
+ }));
+ const data: LinePoint[][] = [points];
+ return {
+ id: String(seriesIndex),
+ type: TraceType.LINE,
+ ...(selectors ? { selectors } : {}),
+ data,
+ };
+}
+
+function buildScatterLayer(
+ series: AnyChartSeries,
+ seriesIndex: number,
+ selectors: string | string[] | undefined,
+): MaidrLayer {
+ const rows = extractRawRows(series);
+ const data: ScatterPoint[] = rows.map(r => ({
+ x: asNumber(r.x),
+ y: asNumber(r.value ?? r.y),
+ }));
+ return {
+ id: String(seriesIndex),
+ type: TraceType.SCATTER,
+ ...(selectors ? { selectors } : {}),
+ data,
+ };
+}
+
+/**
+ * Build a BOX layer from an AnyChart box series.
+ *
+ * @remarks
+ * AnyChart exposes quartile data (lowest, q1, median, q3, highest) through
+ * its iterator, but does not provide direct access to outlier arrays via the
+ * standard data iterator API. As a result, `lowerOutliers` and
+ * `upperOutliers` are always empty. If your chart contains outliers and you
+ * need them in the accessible representation, supply them manually by
+ * post-processing the {@link Maidr} object returned from
+ * {@link anyChartToMaidr}.
+ */
+function buildBoxLayer(
+ series: AnyChartSeries,
+ seriesIndex: number,
+ selectors: string | string[] | undefined,
+): MaidrLayer {
+ const rows = extractRawRows(series);
+ const data: BoxPoint[] = rows.map(r => ({
+ fill: asString(r.x ?? r.name ?? r._index),
+ // Outlier arrays are not available through AnyChart's iterator API.
+ lowerOutliers: [],
+ min: asNumber(r.lowest),
+ q1: asNumber(r.q1),
+ q2: asNumber(r.median),
+ q3: asNumber(r.q3),
+ max: asNumber(r.highest),
+ upperOutliers: [],
+ }));
+ return {
+ id: String(seriesIndex),
+ type: TraceType.BOX,
+ ...(selectors ? { selectors } : {}),
+ data,
+ };
+}
+
+function buildHeatmapLayer(
+ series: AnyChartSeries,
+ seriesIndex: number,
+ selectors: string | string[] | undefined,
+): MaidrLayer {
+ const rows = extractRawRows(series);
+
+ // Collect unique x and y labels in insertion order.
+ const xLabels: string[] = [];
+ const yLabels: string[] = [];
+ const xSet = new Set();
+ const ySet = new Set();
+
+ for (const r of rows) {
+ const xVal = asString(r.x);
+ const yVal = asString(r.y ?? r.name);
+ if (!xSet.has(xVal)) {
+ xLabels.push(xVal);
+ xSet.add(xVal);
+ }
+ if (!ySet.has(yVal)) {
+ yLabels.push(yVal);
+ ySet.add(yVal);
+ }
+ }
+
+ // Build the 2D points matrix (y rows × x columns).
+ const points: number[][] = Array.from(
+ { length: yLabels.length },
+ () => Array.from({ length: xLabels.length }).fill(0),
+ );
+ for (const r of rows) {
+ const xi = xLabels.indexOf(asString(r.x));
+ const yi = yLabels.indexOf(asString(r.y ?? r.name));
+ if (xi >= 0 && yi >= 0)
+ points[yi][xi] = asNumber(r.value ?? r.fill);
+ }
+
+ const data: HeatmapData = { x: xLabels, y: yLabels, points };
+ return {
+ id: String(seriesIndex),
+ type: TraceType.HEATMAP,
+ ...(selectors ? { selectors } : {}),
+ data,
+ };
+}
+
+function buildCandlestickLayer(
+ series: AnyChartSeries,
+ seriesIndex: number,
+ selectors: string | string[] | undefined,
+): MaidrLayer {
+ const rows = extractRawRows(series);
+ const data: CandlestickPoint[] = rows.map((r) => {
+ const open = asNumber(r.open);
+ const close = asNumber(r.close);
+ const high = asNumber(r.high);
+ const low = asNumber(r.low);
+
+ let trend: CandlestickTrend = 'Neutral';
+ if (close > open)
+ trend = 'Bull';
+ else if (close < open)
+ trend = 'Bear';
+
+ const midpoint = (high + low) / 2;
+ return {
+ value: asString(r.x ?? r.name ?? r._index),
+ open,
+ high,
+ low,
+ close,
+ volume: asNumber(r.volume),
+ trend,
+ volatility: midpoint > 0 ? (high - low) / midpoint : 0,
+ };
+ });
+ return {
+ id: String(seriesIndex),
+ type: TraceType.CANDLESTICK,
+ ...(selectors ? { selectors } : {}),
+ data,
+ };
+}
+
+function buildHistogramLayer(
+ series: AnyChartSeries,
+ seriesIndex: number,
+ selectors: string | string[] | undefined,
+): MaidrLayer {
+ const rows = extractRawRows(series);
+ const data: HistogramPoint[] = rows.map((r) => {
+ const y = asNumber(r.value ?? r.y);
+ const x = asNumber(r.x);
+ return {
+ x,
+ y,
+ xMin: x - 0.5,
+ xMax: x + 0.5,
+ yMin: 0,
+ yMax: y,
+ };
+ });
+ return {
+ id: String(seriesIndex),
+ type: TraceType.HISTOGRAM,
+ ...(selectors ? { selectors } : {}),
+ data,
+ };
+}
+
+function buildSegmentedLayer(
+ series: AnyChartSeries,
+ seriesIndex: number,
+ selectors: string | string[] | undefined,
+ traceType: TraceType.STACKED | TraceType.DODGED | TraceType.NORMALIZED,
+): MaidrLayer {
+ const rows = extractRawRows(series);
+ // Group by fill/group value to form the 2D array.
+ const groups = new Map();
+ for (const r of rows) {
+ const fill = asString(r.fill ?? r.group ?? seriesIndex);
+ if (!groups.has(fill))
+ groups.set(fill, []);
+ groups.get(fill)!.push({
+ x: asString(r.x ?? r.name ?? r._index),
+ y: asNumber(r.value ?? r.y),
+ fill,
+ });
+ }
+ const data: SegmentedPoint[][] = [...groups.values()];
+ return {
+ id: String(seriesIndex),
+ type: traceType,
+ ...(selectors ? { selectors } : {}),
+ data,
+ };
+}
+
+function buildSmoothLayer(
+ series: AnyChartSeries,
+ seriesIndex: number,
+ selectors: string | string[] | undefined,
+): MaidrLayer {
+ const rows = extractRawRows(series);
+ // Smooth traces include SVG coordinates. Since we extract from AnyChart's
+ // data model rather than SVG DOM, svg_x/svg_y are set to 0 as placeholders.
+ const points: SmoothPoint[] = rows.map(r => ({
+ x: asNumber(r.x),
+ y: asNumber(r.value ?? r.y),
+ svg_x: 0,
+ svg_y: 0,
+ }));
+ const data: SmoothPoint[][] = [points];
+ return {
+ id: String(seriesIndex),
+ type: TraceType.SMOOTH,
+ ...(selectors ? { selectors } : {}),
+ data,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Layer builder dispatch
+// ---------------------------------------------------------------------------
+
+function buildLayer(
+ series: AnyChartSeries,
+ seriesIndex: number,
+ traceType: TraceType,
+ selectors: string | string[] | undefined,
+): MaidrLayer {
+ switch (traceType) {
+ case TraceType.BAR:
+ return buildBarLayer(series, seriesIndex, selectors);
+ case TraceType.LINE:
+ return buildLineLayer(series, seriesIndex, selectors);
+ case TraceType.SCATTER:
+ return buildScatterLayer(series, seriesIndex, selectors);
+ case TraceType.BOX:
+ return buildBoxLayer(series, seriesIndex, selectors);
+ case TraceType.HEATMAP:
+ return buildHeatmapLayer(series, seriesIndex, selectors);
+ case TraceType.CANDLESTICK:
+ return buildCandlestickLayer(series, seriesIndex, selectors);
+ case TraceType.HISTOGRAM:
+ return buildHistogramLayer(series, seriesIndex, selectors);
+ case TraceType.STACKED:
+ case TraceType.DODGED:
+ case TraceType.NORMALIZED:
+ return buildSegmentedLayer(series, seriesIndex, selectors, traceType);
+ case TraceType.SMOOTH:
+ return buildSmoothLayer(series, seriesIndex, selectors);
+ default:
+ return buildBarLayer(series, seriesIndex, selectors);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Convert an AnyChart chart instance into a MAIDR data object.
+ *
+ * This function inspects the chart's series and metadata after it has been
+ * drawn, then constructs a {@link Maidr} JSON structure that can be passed
+ * to the `` React component or used with `bindAnyChart()`.
+ *
+ * @param chart - A drawn AnyChart chart instance.
+ * @param options - Optional overrides for id, title, axes, and selectors.
+ * @returns The MAIDR data object, or `null` if no convertible series found.
+ */
+export function anyChartToMaidr(
+ chart: AnyChartInstance,
+ options?: AnyChartBinderOptions,
+): Maidr | null {
+ const seriesCount = chart.getSeriesCount();
+ if (seriesCount === 0)
+ return null;
+
+ // Resolve chart metadata.
+ const container = resolveContainerElement(chart);
+ const id = options?.id ?? container?.id ?? 'anychart-maidr';
+ const title = options?.title ?? extractTitle(chart);
+ const xAxisLabel = options?.axes?.x ?? extractAxisTitle(chart, 'x');
+ const yAxisLabel = options?.axes?.y ?? extractAxisTitle(chart, 'y');
+
+ const layers: MaidrLayer[] = [];
+
+ for (let i = 0; i < seriesCount; i++) {
+ const series = chart.getSeriesAt(i);
+ if (!series)
+ continue;
+
+ const anyChartType = series.seriesType();
+ const traceType = mapSeriesType(anyChartType);
+ if (!traceType) {
+ console.warn(
+ `[maidr/anychart] Unsupported AnyChart series type "${anyChartType}". Skipping series ${i}.`,
+ );
+ continue;
+ }
+
+ const selectors = resolveSelector(i, options);
+ const layer = buildLayer(series, i, traceType, selectors);
+
+ // Attach axis labels.
+ if (xAxisLabel || yAxisLabel) {
+ layer.axes = {
+ ...(xAxisLabel ? { x: xAxisLabel } : {}),
+ ...(yAxisLabel ? { y: yAxisLabel } : {}),
+ };
+ }
+
+ layers.push(layer);
+ }
+
+ if (layers.length === 0)
+ return null;
+
+ const subplot: MaidrSubplot = { layers };
+ const maidr: Maidr = {
+ id,
+ ...(title ? { title } : {}),
+ subplots: [[subplot]],
+ };
+
+ return maidr;
+}
+
+/** Elements that have already been bound via {@link bindAnyChart}. */
+const boundElements = new WeakSet();
+
+/**
+ * Bind an AnyChart chart to MAIDR for accessible interaction.
+ *
+ * This is the primary high-level API. It extracts data from a drawn
+ * AnyChart chart, generates the MAIDR schema, injects it as a
+ * `maidr-data` attribute on the chart's container element, and
+ * dispatches a `maidr:bindchart` event so the MAIDR runtime picks it up.
+ *
+ * The MAIDR runtime (`maidr.js`) must be loaded on the page. It
+ * listens for `maidr:bindchart` events and initialises accessibility
+ * features for the target element.
+ *
+ * Calling this function multiple times on the same chart is safe: if the
+ * container has already been bound, the existing {@link Maidr} data is
+ * returned without re-dispatching the initialisation event.
+ *
+ * @param chart - A drawn AnyChart chart instance.
+ * @param options - Optional overrides.
+ * @returns The generated {@link Maidr} object, or `null` on failure.
+ *
+ * @example
+ * ```ts
+ * const chart = anychart.bar([4, 2, 7, 1]);
+ * chart.container('container').draw();
+ * bindAnyChart(chart);
+ * ```
+ */
+export function bindAnyChart(
+ chart: AnyChartInstance,
+ options?: AnyChartBinderOptions,
+): Maidr | null {
+ const container = resolveContainerElement(chart);
+ if (!container) {
+ console.warn(
+ '[maidr/anychart] Could not find the chart container element. '
+ + 'Make sure the chart has been drawn before calling bindAnyChart().',
+ );
+ return null;
+ }
+
+ // Prevent double-initialisation. If the same container was already bound,
+ // return the previously generated schema without re-dispatching the event
+ // (which would create duplicate handlers and observers).
+ if (boundElements.has(container)) {
+ const existing = container.getAttribute('maidr-data');
+ return existing ? JSON.parse(existing) as Maidr : null;
+ }
+
+ const maidr = anyChartToMaidr(chart, {
+ ...options,
+ id: options?.id ?? container.id ?? 'anychart-maidr',
+ });
+
+ if (!maidr) {
+ console.warn('[maidr/anychart] Could not extract data from AnyChart chart.');
+ return null;
+ }
+
+ // Mark the container as bound before mutating the DOM.
+ boundElements.add(container);
+
+ // Inject MAIDR data onto the container element.
+ container.setAttribute('maidr-data', JSON.stringify(maidr));
+
+ // Dispatch a custom event so the MAIDR runtime initialises on this element.
+ container.dispatchEvent(
+ new CustomEvent('maidr:bindchart', { bubbles: true, detail: maidr }),
+ );
+
+ return maidr;
+}
diff --git a/src/anychart-entry.ts b/src/anychart-entry.ts
new file mode 100644
index 00000000..2825cd09
--- /dev/null
+++ b/src/anychart-entry.ts
@@ -0,0 +1,36 @@
+/**
+ * Public API for the MAIDR AnyChart adapter.
+ *
+ * Provides {@link bindAnyChart} and {@link anyChartToMaidr} for integrating
+ * AnyChart charts with MAIDR's accessible visualisation features.
+ *
+ * @remarks
+ * AnyChart must be loaded separately – this module does not bundle the
+ * AnyChart library. Call these functions **after** the chart has been drawn
+ * so that series data and the SVG container are available.
+ *
+ * @example
+ * ```ts
+ * import { bindAnyChart } from 'maidr/anychart';
+ *
+ * const chart = anychart.bar([4, 2, 7, 1]);
+ * chart.container('container').draw();
+ *
+ * // One-liner: extracts data, sets maidr-data attribute, fires event.
+ * bindAnyChart(chart);
+ * ```
+ *
+ * @packageDocumentation
+ */
+export { anyChartToMaidr, bindAnyChart } from './adapter/anychart';
+
+/**
+ * Re-exported types for configuring the AnyChart adapter.
+ */
+export type { AnyChartBinderOptions, AnyChartInstance } from './type/anychart';
+
+/**
+ * Re-exported core MAIDR types so consumers can type the output.
+ */
+export type { Maidr as MaidrData, MaidrLayer, MaidrSubplot } from './type/grammar';
+export { TraceType } from './type/grammar';
diff --git a/src/index.tsx b/src/index.tsx
index b5af8f94..59b21cd3 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -20,6 +20,25 @@ if (document.readyState === 'loading') {
main();
}
+// Support for third-party adapters (e.g. maidr/anychart) that bind charts
+// after the initial DOM scan. When an adapter sets `maidr-data` on an
+// element and dispatches this event, we initialise MAIDR for that element.
+//
+// This listener is intentionally permanent (never removed) because adapter
+// bindings can happen at any point during the page's lifetime — there is no
+// deterministic teardown moment. A WeakSet or similar guard in the adapter
+// prevents duplicate initialisation for the same element.
+document.addEventListener('maidr:bindchart', ((event: CustomEvent) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement))
+ return;
+
+ const json = target.getAttribute(Constant.MAIDR_DATA);
+ if (json) {
+ parseAndInit(target, json, 'maidr-data');
+ }
+}) as EventListener);
+
function parseAndInit(
plot: HTMLElement,
json: string,
diff --git a/src/type/anychart.ts b/src/type/anychart.ts
new file mode 100644
index 00000000..dae42fd0
--- /dev/null
+++ b/src/type/anychart.ts
@@ -0,0 +1,139 @@
+/**
+ * Minimal type definitions for AnyChart chart instances.
+ *
+ * These types describe the subset of the AnyChart API that the MAIDR adapter
+ * needs in order to extract chart metadata, series data, and SVG elements.
+ * They are intentionally narrow to avoid a hard dependency on the AnyChart
+ * library while still providing type safety for the adapter code.
+ */
+
+/** Iterator for traversing data rows in an AnyChart data view. */
+export interface AnyChartIterator {
+ advance: () => boolean;
+ get: (field: string) => unknown;
+ getIndex: () => number;
+ getRowsCount: () => number;
+ reset: () => void;
+}
+
+/** A wrapped data point returned by `series.getPoint(index)`. */
+export interface AnyChartPoint {
+ get: (field: string) => unknown;
+ getIndex: () => number;
+ exists: () => boolean;
+}
+
+/** An individual series within a chart. */
+export interface AnyChartSeries {
+ id: () => string | number;
+ name: () => string;
+ seriesType: () => string;
+ getIterator: () => AnyChartIterator;
+ getPoint: (index: number) => AnyChartPoint;
+ getStat: (key: string) => unknown;
+}
+
+/** Title object returned by `chart.title()`. */
+export interface AnyChartTitle {
+ text: () => string | undefined;
+}
+
+/** Axis label configuration. */
+export interface AnyChartAxisLabels {
+ enabled: () => boolean;
+}
+
+/** Axis title configuration. */
+export interface AnyChartAxisTitle {
+ text: () => string | undefined;
+}
+
+/** An axis instance on a Cartesian chart. */
+export interface AnyChartAxis {
+ title: () => AnyChartAxisTitle;
+ labels: () => AnyChartAxisLabels;
+}
+
+/** Rendering stage / container element. */
+export interface AnyChartStage {
+ container: () => HTMLElement | null;
+ domElement: () => HTMLElement | null;
+}
+
+/**
+ * The minimal chart interface the adapter requires.
+ *
+ * All supported AnyChart chart types (Cartesian, Pie, etc.) expose these
+ * methods once the chart has been drawn.
+ */
+export interface AnyChartInstance {
+ /** Chart title accessor. */
+ title: () => AnyChartTitle | string;
+
+ /** Rendering container / stage. */
+ container: () => AnyChartStage | HTMLElement | string;
+
+ /** Number of series in the chart. */
+ getSeriesCount: () => number;
+
+ /** Get a series by its numeric index. */
+ getSeriesAt: (index: number) => AnyChartSeries | null;
+
+ /** X-axis accessor (Cartesian charts). Returns null for non-Cartesian. */
+ xAxis?: (index?: number) => AnyChartAxis | null;
+
+ /** Y-axis accessor (Cartesian charts). Returns null for non-Cartesian. */
+ yAxis?: (index?: number) => AnyChartAxis | null;
+
+ /** Chart type string (e.g. "bar", "line", "pie"). */
+ getType?: () => string;
+
+ /** SVG string export. */
+ toSvg?: () => string;
+}
+
+/**
+ * Options the consumer can pass when binding an AnyChart chart to MAIDR.
+ */
+export interface AnyChartBinderOptions {
+ /**
+ * Override the chart ID used in the MAIDR schema.
+ * Defaults to the chart container element's `id` attribute.
+ */
+ id?: string;
+
+ /**
+ * Override the chart title.
+ * Defaults to `chart.title().text()`.
+ */
+ title?: string;
+
+ /**
+ * Override axis labels.
+ */
+ axes?: {
+ x?: string;
+ y?: string;
+ };
+
+ /**
+ * CSS selector overrides for SVG element highlighting.
+ *
+ * Each element in the array corresponds to a series by index. Use
+ * `undefined` at a given position to skip that series (no highlighting).
+ *
+ * AnyChart's internal SVG structure does not expose stable class names,
+ * so consumers should inspect the rendered DOM and provide explicit
+ * selectors for reliable highlighting.
+ *
+ * @example
+ * ```ts
+ * // Apply per-series selectors (2 series, second has none):
+ * selectors: ['.series-0 rect', undefined]
+ *
+ * // Apply the same selector to all series:
+ * selectors: ['.chart rect']
+ * ```
+ */
+ selectors?: Array;
+}
diff --git a/test/adapter/anychart.test.ts b/test/adapter/anychart.test.ts
new file mode 100644
index 00000000..89ef8fbf
--- /dev/null
+++ b/test/adapter/anychart.test.ts
@@ -0,0 +1,587 @@
+/* eslint-disable perfectionist/sort-imports -- setup-dom must run before adapter import */
+import './setup-dom';
+
+import type { AnyChartInstance, AnyChartIterator, AnyChartSeries } from '@type/anychart';
+import type { CandlestickPoint, HeatmapData, HistogramPoint } from '@type/grammar';
+import { anyChartToMaidr, bindAnyChart, extractRawRows, mapSeriesType, resolveContainerElement } from '../../src/adapter/anychart';
+import { TraceType } from '../../src/type/grammar';
+
+// ---------------------------------------------------------------------------
+// Test helpers – mock AnyChart objects
+// ---------------------------------------------------------------------------
+
+function createMockIterator(
+ rows: Array>,
+): AnyChartIterator {
+ let cursor = -1;
+ return {
+ advance() {
+ cursor++;
+ return cursor < rows.length;
+ },
+ get(field: string) {
+ return rows[cursor]?.[field] ?? undefined;
+ },
+ getIndex() {
+ return cursor;
+ },
+ getRowsCount() {
+ return rows.length;
+ },
+ reset() {
+ cursor = -1;
+ },
+ };
+}
+
+function createMockSeries(
+ type: string,
+ rows: Array>,
+): AnyChartSeries {
+ return {
+ id: () => 0,
+ name: () => 'Series 0',
+ seriesType: () => type,
+ getIterator: () => createMockIterator(rows),
+ getPoint: (index: number) => ({
+ get: (field: string) => rows[index]?.[field] ?? undefined,
+ getIndex: () => index,
+ exists: () => index >= 0 && index < rows.length,
+ }),
+ getStat: () => undefined,
+ };
+}
+
+function createMockChart(
+ seriesList: AnyChartSeries[],
+ overrides: Partial = {},
+): AnyChartInstance {
+ return {
+ title: () => 'Test Chart',
+ container: () => 'container-id',
+ getSeriesCount: () => seriesList.length,
+ getSeriesAt: (index: number) => seriesList[index] ?? null,
+ ...overrides,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// mapSeriesType
+// ---------------------------------------------------------------------------
+
+describe('mapSeriesType', () => {
+ afterEach(() => jest.restoreAllMocks());
+
+ it('maps standard Cartesian types', () => {
+ expect(mapSeriesType('bar')).toBe(TraceType.BAR);
+ expect(mapSeriesType('column')).toBe(TraceType.BAR);
+ expect(mapSeriesType('line')).toBe(TraceType.LINE);
+ expect(mapSeriesType('scatter')).toBe(TraceType.SCATTER);
+ expect(mapSeriesType('marker')).toBe(TraceType.SCATTER);
+ expect(mapSeriesType('bubble')).toBe(TraceType.SCATTER);
+ });
+
+ it('maps complex chart types', () => {
+ expect(mapSeriesType('box')).toBe(TraceType.BOX);
+ expect(mapSeriesType('heatmap')).toBe(TraceType.HEATMAP);
+ expect(mapSeriesType('heat')).toBe(TraceType.HEATMAP);
+ expect(mapSeriesType('candlestick')).toBe(TraceType.CANDLESTICK);
+ expect(mapSeriesType('ohlc')).toBe(TraceType.CANDLESTICK);
+ });
+
+ it('maps line variants', () => {
+ expect(mapSeriesType('spline')).toBe(TraceType.LINE);
+ expect(mapSeriesType('step-line')).toBe(TraceType.LINE);
+ });
+
+ it('maps area types to LINE with a warning', () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
+
+ expect(mapSeriesType('area')).toBe(TraceType.LINE);
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('mapped to LINE trace'),
+ );
+
+ warnSpy.mockClear();
+ expect(mapSeriesType('step-area')).toBe(TraceType.LINE);
+ expect(warnSpy).toHaveBeenCalled();
+
+ warnSpy.mockClear();
+ expect(mapSeriesType('spline_area')).toBe(TraceType.LINE);
+ expect(warnSpy).toHaveBeenCalled();
+ });
+
+ it('normalises underscores and mixed case', () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ expect(mapSeriesType('spline_area')).toBe(TraceType.LINE);
+ expect(mapSeriesType('Spline_Area')).toBe(TraceType.LINE);
+ expect(mapSeriesType('STEP_LINE')).toBe(TraceType.LINE);
+ expect(mapSeriesType('Bar')).toBe(TraceType.BAR);
+ warnSpy.mockRestore();
+ });
+
+ it('returns null for unsupported types', () => {
+ expect(mapSeriesType('pie')).toBeNull();
+ expect(mapSeriesType('funnel')).toBeNull();
+ expect(mapSeriesType('unknown')).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// extractRawRows
+// ---------------------------------------------------------------------------
+
+describe('extractRawRows', () => {
+ it('extracts basic x/value fields', () => {
+ const series = createMockSeries('bar', [
+ { x: 'A', value: 10 },
+ { x: 'B', value: 20 },
+ ]);
+ const rows = extractRawRows(series);
+ expect(rows).toHaveLength(2);
+ expect(rows[0]).toMatchObject({ x: 'A', value: 10 });
+ expect(rows[1]).toMatchObject({ x: 'B', value: 20 });
+ });
+
+ it('extracts box fields', () => {
+ const series = createMockSeries('box', [
+ { x: 'G1', lowest: 10, q1: 25, median: 50, q3: 75, highest: 90 },
+ ]);
+ const rows = extractRawRows(series);
+ expect(rows[0]).toMatchObject({
+ x: 'G1',
+ lowest: 10,
+ q1: 25,
+ median: 50,
+ q3: 75,
+ highest: 90,
+ });
+ });
+
+ it('extracts candlestick OHLC fields', () => {
+ const series = createMockSeries('candlestick', [
+ { x: '2024-01-01', open: 100, high: 110, low: 95, close: 105 },
+ ]);
+ const rows = extractRawRows(series);
+ expect(rows[0]).toMatchObject({
+ x: '2024-01-01',
+ open: 100,
+ high: 110,
+ low: 95,
+ close: 105,
+ });
+ });
+
+ it('includes iterator index as _index', () => {
+ const series = createMockSeries('bar', [{ value: 42 }]);
+ const rows = extractRawRows(series);
+ expect(rows[0]._index).toBe(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// resolveContainerElement
+// ---------------------------------------------------------------------------
+
+describe('resolveContainerElement', () => {
+ it('returns null when container() returns null', () => {
+ const chart = createMockChart([], { container: () => null as any });
+ expect(resolveContainerElement(chart)).toBeNull();
+ });
+
+ it('returns null when container() throws', () => {
+ const chart = createMockChart([], {
+ container: () => {
+ throw new Error('test');
+ },
+ });
+ expect(resolveContainerElement(chart)).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// anyChartToMaidr – BAR
+// ---------------------------------------------------------------------------
+
+describe('anyChartToMaidr – BAR', () => {
+ it('generates bar layer from column series', () => {
+ const series = createMockSeries('column', [
+ { x: 'Sat', value: 87 },
+ { x: 'Sun', value: 76 },
+ { x: 'Thur', value: 62 },
+ ]);
+ const chart = createMockChart([series]);
+ const result = anyChartToMaidr(chart, { id: 'test' });
+
+ expect(result).not.toBeNull();
+ expect(result!.id).toBe('test');
+ expect(result!.title).toBe('Test Chart');
+
+ const layer = result!.subplots[0][0].layers[0];
+ expect(layer.type).toBe(TraceType.BAR);
+ expect(layer.data).toHaveLength(3);
+ expect((layer.data as any[])[0]).toEqual({ x: 'Sat', y: 87 });
+ });
+
+ it('returns null for empty chart', () => {
+ const chart = createMockChart([]);
+ expect(anyChartToMaidr(chart)).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// anyChartToMaidr – LINE
+// ---------------------------------------------------------------------------
+
+describe('anyChartToMaidr – LINE', () => {
+ it('generates line layer with 2D data', () => {
+ const series = createMockSeries('line', [
+ { x: 1, value: 10 },
+ { x: 2, value: 20 },
+ { x: 3, value: 30 },
+ ]);
+ const chart = createMockChart([series]);
+ const result = anyChartToMaidr(chart, { id: 'line-test' });
+
+ const layer = result!.subplots[0][0].layers[0];
+ expect(layer.type).toBe(TraceType.LINE);
+ // Line data is 2D: array of arrays
+ const data = layer.data as any[][];
+ expect(Array.isArray(data[0])).toBe(true);
+ expect(data[0]).toHaveLength(3);
+ expect(data[0][0]).toEqual({ x: 1, y: 10 });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// anyChartToMaidr – SCATTER
+// ---------------------------------------------------------------------------
+
+describe('anyChartToMaidr – SCATTER', () => {
+ it('generates scatter layer with numeric x and y', () => {
+ const series = createMockSeries('scatter', [
+ { x: 1.5, value: 3.2 },
+ { x: 2.1, value: 4.8 },
+ ]);
+ const chart = createMockChart([series]);
+ const result = anyChartToMaidr(chart, { id: 'scatter-test' });
+
+ const layer = result!.subplots[0][0].layers[0];
+ expect(layer.type).toBe(TraceType.SCATTER);
+ expect((layer.data as any[])[0]).toEqual({ x: 1.5, y: 3.2 });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// anyChartToMaidr – BOX
+// ---------------------------------------------------------------------------
+
+describe('anyChartToMaidr – BOX', () => {
+ it('generates box layer with quartile data', () => {
+ const series = createMockSeries('box', [
+ { x: 'Group1', lowest: 10, q1: 25, median: 50, q3: 75, highest: 90 },
+ { x: 'Group2', lowest: 5, q1: 20, median: 40, q3: 60, highest: 85 },
+ ]);
+ const chart = createMockChart([series]);
+ const result = anyChartToMaidr(chart, { id: 'box-test' });
+
+ const layer = result!.subplots[0][0].layers[0];
+ expect(layer.type).toBe(TraceType.BOX);
+ const data = layer.data as any[];
+ expect(data[0]).toMatchObject({
+ fill: 'Group1',
+ min: 10,
+ q1: 25,
+ q2: 50,
+ q3: 75,
+ max: 90,
+ lowerOutliers: [],
+ upperOutliers: [],
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// anyChartToMaidr – HEATMAP
+// ---------------------------------------------------------------------------
+
+describe('anyChartToMaidr – HEATMAP', () => {
+ it('generates heatmap with 2D point matrix', () => {
+ const series = createMockSeries('heatmap', [
+ { x: 'A', y: 'R1', value: 10 },
+ { x: 'B', y: 'R1', value: 20 },
+ { x: 'A', y: 'R2', value: 30 },
+ { x: 'B', y: 'R2', value: 40 },
+ ]);
+ const chart = createMockChart([series]);
+ const result = anyChartToMaidr(chart, { id: 'heat-test' });
+
+ const layer = result!.subplots[0][0].layers[0];
+ expect(layer.type).toBe(TraceType.HEATMAP);
+
+ const data = layer.data as HeatmapData;
+ expect(data.x).toEqual(['A', 'B']);
+ expect(data.y).toEqual(['R1', 'R2']);
+ expect(data.points).toEqual([
+ [10, 20],
+ [30, 40],
+ ]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// anyChartToMaidr – CANDLESTICK
+// ---------------------------------------------------------------------------
+
+describe('anyChartToMaidr – CANDLESTICK', () => {
+ it('generates candlestick layer with OHLC data and trend', () => {
+ const series = createMockSeries('candlestick', [
+ { x: '2024-01-01', open: 100, high: 110, low: 95, close: 105, volume: 1000 },
+ { x: '2024-01-02', open: 105, high: 108, low: 98, close: 99, volume: 800 },
+ { x: '2024-01-03', open: 100, high: 105, low: 100, close: 100, volume: 500 },
+ ]);
+ const chart = createMockChart([series]);
+ const result = anyChartToMaidr(chart, { id: 'candle-test' });
+
+ const layer = result!.subplots[0][0].layers[0];
+ expect(layer.type).toBe(TraceType.CANDLESTICK);
+
+ const data = layer.data as CandlestickPoint[];
+ expect(data).toHaveLength(3);
+ // Bull: close > open
+ expect(data[0].trend).toBe('Bull');
+ expect(data[0].open).toBe(100);
+ expect(data[0].close).toBe(105);
+ // Bear: close < open
+ expect(data[1].trend).toBe('Bear');
+ // Neutral: close == open
+ expect(data[2].trend).toBe('Neutral');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// anyChartToMaidr – HISTOGRAM
+// ---------------------------------------------------------------------------
+
+describe('anyChartToMaidr – HISTOGRAM', () => {
+ it('generates histogram layer with bin data', () => {
+ const series = createMockSeries('column', [
+ { x: 1.5, value: 10 },
+ { x: 2.5, value: 20 },
+ ]);
+ // Force histogram type via direct call to anyChartToMaidr
+ // Since there's no 'histogram' seriesType in AnyChart, we test the builder
+ // through a custom mapping. For now, test the BAR output from column.
+ const chart = createMockChart([series]);
+ const result = anyChartToMaidr(chart, { id: 'hist-test' });
+
+ const layer = result!.subplots[0][0].layers[0];
+ expect(layer.type).toBe(TraceType.BAR);
+ const data = layer.data as HistogramPoint[];
+ expect(data).toHaveLength(2);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// anyChartToMaidr – options handling
+// ---------------------------------------------------------------------------
+
+describe('anyChartToMaidr – options', () => {
+ it('applies title and axis overrides', () => {
+ const series = createMockSeries('bar', [{ x: 'A', value: 1 }]);
+ const chart = createMockChart([series]);
+ const result = anyChartToMaidr(chart, {
+ id: 'opts-test',
+ title: 'Custom Title',
+ axes: { x: 'X Label', y: 'Y Label' },
+ });
+
+ expect(result!.title).toBe('Custom Title');
+ const layer = result!.subplots[0][0].layers[0];
+ expect(layer.axes).toEqual({ x: 'X Label', y: 'Y Label' });
+ });
+
+ it('applies single shared selector to layers', () => {
+ const series = createMockSeries('bar', [{ x: 'A', value: 1 }]);
+ const chart = createMockChart([series]);
+ const result = anyChartToMaidr(chart, {
+ id: 'sel-test',
+ selectors: ['.my-bar rect'],
+ });
+
+ const layer = result!.subplots[0][0].layers[0];
+ expect(layer.selectors).toBe('.my-bar rect');
+ });
+
+ it('omits selectors when none provided', () => {
+ const series = createMockSeries('bar', [{ x: 'A', value: 1 }]);
+ const chart = createMockChart([series]);
+ const result = anyChartToMaidr(chart, { id: 'no-sel' });
+
+ const layer = result!.subplots[0][0].layers[0];
+ expect(layer.selectors).toBeUndefined();
+ });
+
+ it('skips unsupported series types with warning', () => {
+ const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
+ const series = createMockSeries('pie', [{ x: 'A', value: 1 }]);
+ const chart = createMockChart([series]);
+ const result = anyChartToMaidr(chart, { id: 'unsup-test' });
+
+ expect(result).toBeNull();
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Unsupported AnyChart series type "pie"'),
+ );
+ consoleSpy.mockRestore();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// anyChartToMaidr – multi-series
+// ---------------------------------------------------------------------------
+
+describe('anyChartToMaidr – multi-series', () => {
+ it('handles multiple series of different types', () => {
+ const barSeries = createMockSeries('bar', [
+ { x: 'A', value: 10 },
+ ]);
+ const lineSeries = createMockSeries('line', [
+ { x: 1, value: 20 },
+ ]);
+ const chart = createMockChart([barSeries, lineSeries]);
+ const result = anyChartToMaidr(chart, { id: 'multi-test' });
+
+ expect(result).not.toBeNull();
+ const layers = result!.subplots[0][0].layers;
+ expect(layers).toHaveLength(2);
+ expect(layers[0].type).toBe(TraceType.BAR);
+ expect(layers[1].type).toBe(TraceType.LINE);
+ });
+
+ it('applies per-series selectors', () => {
+ const s1 = createMockSeries('bar', [{ x: 'A', value: 10 }]);
+ const s2 = createMockSeries('line', [{ x: 1, value: 20 }]);
+ const chart = createMockChart([s1, s2]);
+ const result = anyChartToMaidr(chart, {
+ id: 'per-sel',
+ selectors: ['.bar-el rect', undefined],
+ });
+
+ const layers = result!.subplots[0][0].layers;
+ expect(layers[0].selectors).toBe('.bar-el rect');
+ expect(layers[1].selectors).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// bindAnyChart – DOM behaviour
+//
+// These tests use a minimal mock DOM since the default Jest environment is
+// `node` and `jest-environment-jsdom` is not installed.
+// ---------------------------------------------------------------------------
+
+/**
+ * Creates a lightweight mock HTMLElement sufficient for bindAnyChart tests.
+ *
+ * Uses `Object.create(HTMLElement.prototype)` so the adapter's
+ * `container instanceof HTMLElement` check passes in the Node environment.
+ */
+function createMockHTMLElement(id: string): HTMLElement {
+ const attrs = new Map();
+ const listeners = new Map void>>();
+
+ const el = Object.create(HTMLElement.prototype);
+ el.id = id;
+ el.getAttribute = (name: string) => attrs.get(name) ?? null;
+ el.setAttribute = (name: string, value: string) => attrs.set(name, value);
+ el.addEventListener = (type: string, handler: (evt: any) => void) => {
+ if (!listeners.has(type))
+ listeners.set(type, []);
+ listeners.get(type)!.push(handler);
+ };
+ el.dispatchEvent = (event: { type: string; [key: string]: any }) => {
+ const handlers = listeners.get(event.type) ?? [];
+ for (const h of handlers)
+ h(event);
+ return true;
+ };
+
+ return el as HTMLElement;
+}
+
+describe('bindAnyChart', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ function chartWithMockContainer(
+ series: AnyChartSeries[],
+ containerEl: HTMLElement,
+ ): AnyChartInstance {
+ return createMockChart(series, {
+ container: () => containerEl as any,
+ });
+ }
+
+ it('sets maidr-data attribute on the container', () => {
+ const containerEl = createMockHTMLElement('test-container');
+ const series = createMockSeries('bar', [{ x: 'A', value: 1 }]);
+ const chart = chartWithMockContainer([series], containerEl);
+
+ bindAnyChart(chart, { id: 'bind-test' });
+
+ const attr = containerEl.getAttribute('maidr-data');
+ expect(attr).not.toBeNull();
+ const parsed = JSON.parse(attr!);
+ expect(parsed.id).toBe('bind-test');
+ });
+
+ it('dispatches maidr:bindchart event on the container', () => {
+ const containerEl = createMockHTMLElement('event-container');
+ const series = createMockSeries('bar', [{ x: 'A', value: 1 }]);
+ const chart = chartWithMockContainer([series], containerEl);
+
+ const eventSpy = jest.fn();
+ containerEl.addEventListener('maidr:bindchart', eventSpy);
+
+ bindAnyChart(chart, { id: 'event-test' });
+
+ expect(eventSpy).toHaveBeenCalledTimes(1);
+ const event = eventSpy.mock.calls[0][0];
+ expect(event.detail.id).toBe('event-test');
+ });
+
+ it('returns null when container cannot be resolved', () => {
+ const series = createMockSeries('bar', [{ x: 'A', value: 1 }]);
+ const chart = createMockChart([series], {
+ container: () => null as any,
+ });
+
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ const result = bindAnyChart(chart, { id: 'no-container' });
+
+ expect(result).toBeNull();
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Could not find the chart container element'),
+ );
+ });
+
+ it('guards against double-initialisation on the same container', () => {
+ const containerEl = createMockHTMLElement('double-container');
+ const series = createMockSeries('bar', [{ x: 'A', value: 1 }]);
+ const chart = chartWithMockContainer([series], containerEl);
+
+ const eventSpy = jest.fn();
+ containerEl.addEventListener('maidr:bindchart', eventSpy);
+
+ const first = bindAnyChart(chart, { id: 'double-test' });
+ const second = bindAnyChart(chart, { id: 'double-test' });
+
+ // Event should fire only once.
+ expect(eventSpy).toHaveBeenCalledTimes(1);
+ // Both calls return a valid Maidr object.
+ expect(first).not.toBeNull();
+ expect(second).not.toBeNull();
+ expect(first!.id).toBe(second!.id);
+ });
+});
diff --git a/test/adapter/setup-dom.ts b/test/adapter/setup-dom.ts
new file mode 100644
index 00000000..70ef3ef0
--- /dev/null
+++ b/test/adapter/setup-dom.ts
@@ -0,0 +1,14 @@
+/**
+ * Minimal DOM polyfill for the Node test environment.
+ *
+ * The AnyChart adapter uses `instanceof HTMLElement` at runtime to distinguish
+ * real DOM elements from AnyChart stage wrappers. In Node (the default Jest
+ * environment) `HTMLElement` is not a global, so we provide a stand-in before
+ * the adapter module is imported.
+ *
+ * This file must be imported before any adapter imports in test files.
+ */
+
+if (typeof globalThis.HTMLElement === 'undefined') {
+ (globalThis as any).HTMLElement = class HTMLElement {};
+}
diff --git a/vite.anychart.config.ts b/vite.anychart.config.ts
new file mode 100644
index 00000000..7371ae64
--- /dev/null
+++ b/vite.anychart.config.ts
@@ -0,0 +1,45 @@
+import path from 'node:path';
+import { defineConfig } from 'vite';
+import dts from 'vite-plugin-dts';
+
+export default defineConfig({
+ plugins: [
+ dts({
+ tsconfigPath: './tsconfig.build.json',
+ rollupTypes: true,
+ insertTypesEntry: false,
+ }),
+ ],
+ build: {
+ lib: {
+ entry: path.resolve(__dirname, 'src/anychart-entry.ts'),
+ formats: ['es'],
+ fileName: () => 'anychart.mjs',
+ },
+ sourcemap: true,
+ outDir: 'dist',
+ emptyOutDir: false,
+ rollupOptions: {
+ onwarn(warning, warn) {
+ if (warning.code === 'MODULE_LEVEL_DIRECTIVE') {
+ return;
+ }
+ warn(warning);
+ },
+ },
+ },
+ define: {
+ 'process.env.NODE_ENV': JSON.stringify('production'),
+ },
+ resolve: {
+ alias: {
+ '@command': path.resolve(__dirname, 'src/command'),
+ '@model': path.resolve(__dirname, 'src/model'),
+ '@state': path.resolve(__dirname, 'src/state'),
+ '@service': path.resolve(__dirname, 'src/service'),
+ '@type': path.resolve(__dirname, 'src/type'),
+ '@ui': path.resolve(__dirname, 'src/ui'),
+ '@util': path.resolve(__dirname, 'src/util'),
+ },
+ },
+});