diff --git a/examples/amcharts-bar.html b/examples/amcharts-bar.html
new file mode 100644
index 00000000..c892304f
--- /dev/null
+++ b/examples/amcharts-bar.html
@@ -0,0 +1,114 @@
+
+
+
+
+ MAIDR + amCharts 5 Example
+
+
+
+
+
+
+
+
+
+
+
+
+ MAIDR + amCharts 5: Bar Chart
+
+ This example demonstrates how to use the MAIDR amCharts binder.
+ After the amCharts chart renders, the binder extracts data and
+ generates a MAIDR JSON object that enables 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..0635c365 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,11 @@
"types": "./dist/react.d.mts",
"import": "./dist/react.mjs",
"default": "./dist/react.mjs"
+ },
+ "./amcharts": {
+ "types": "./dist/amcharts.d.mts",
+ "import": "./dist/amcharts.mjs",
+ "default": "./dist/amcharts.mjs"
}
},
"main": "dist/maidr.js",
@@ -20,7 +25,8 @@
"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.amcharts.config.ts",
+ "build:amcharts": "vite build --config vite.amcharts.config.ts",
"build:script": "vite build",
"build:react": "vite build --config vite.react.config.ts",
"prepublishOnly": "npm run build",
diff --git a/src/amcharts-entry.ts b/src/amcharts-entry.ts
new file mode 100644
index 00000000..8df2f693
--- /dev/null
+++ b/src/amcharts-entry.ts
@@ -0,0 +1,36 @@
+/**
+ * Public amCharts 5 binder API for MAIDR.
+ *
+ * Provides adapter functions that convert amCharts 5 chart instances into
+ * MAIDR-compatible data objects. The resulting data can be passed to the
+ * `` React component or embedded as a `maidr-data` HTML attribute.
+ *
+ * @remarks
+ * amCharts 5 is a commercial charting library and is **not** bundled with
+ * MAIDR. Consumers must install amCharts 5 separately.
+ *
+ * @example
+ * ```ts
+ * import { fromAmCharts } from 'maidr/amcharts';
+ * import { Maidr } from 'maidr/react';
+ *
+ * // 1. Create your amCharts 5 chart as usual.
+ * const root = am5.Root.new("chartdiv");
+ * const chart = root.container.children.push(
+ * am5xy.XYChart.new(root, {})
+ * );
+ * // ... add axes, series, data ...
+ *
+ * // 2. Convert to MAIDR data.
+ * const data = fromAmCharts(root);
+ *
+ * // 3. Use with the Maidr React component.
+ *
+ *
+ *
+ * ```
+ *
+ * @packageDocumentation
+ */
+export { fromAmCharts, fromXYChart } from './binder/amcharts/adapter';
+export type { AmChartsBinderOptions, AmRoot, AmXYChart, AmXYSeries } from './binder/amcharts/types';
diff --git a/src/binder/amcharts/adapter.ts b/src/binder/amcharts/adapter.ts
new file mode 100644
index 00000000..2d5607b1
--- /dev/null
+++ b/src/binder/amcharts/adapter.ts
@@ -0,0 +1,454 @@
+/**
+ * Main adapter that converts an amCharts 5 chart into a MAIDR data object.
+ *
+ * @example
+ * ```ts
+ * import { fromAmCharts } from 'maidr/amcharts';
+ *
+ * const root = am5.Root.new("chartdiv");
+ * const chart = root.container.children.push(
+ * am5xy.XYChart.new(root, {})
+ * );
+ * // ... configure chart, axes, series, data ...
+ *
+ * const maidrData = fromAmCharts(root);
+ * ```
+ */
+
+import type {
+ BarPoint,
+ CandlestickPoint,
+ HeatmapData,
+ HistogramPoint,
+ LinePoint,
+ Maidr,
+ MaidrLayer,
+ MaidrSubplot,
+ ScatterPoint,
+ SegmentedPoint,
+} from '@type/grammar';
+import type {
+ AmChartsBinderOptions,
+ AmRoot,
+ AmXYChart,
+ AmXYSeries,
+} from './types';
+import { Orientation, TraceType } from '@type/grammar';
+import {
+ classifySeriesKind,
+ extractBarPoints,
+ extractCandlestickPoints,
+ extractHeatmapData,
+ extractHistogramPoints,
+ extractLinePoints,
+ extractScatterPoints,
+ extractSegmentedPoints,
+ readAxisLabel,
+} from './extractors';
+import {
+ buildColumnSelector,
+ buildLineSelector,
+ buildScatterSelector,
+} from './selectors';
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Convert an amCharts 5 {@link AmRoot} into a MAIDR data object.
+ *
+ * The function inspects the root's container children, finds the first
+ * XY chart, and converts each of its series into a MAIDR layer.
+ *
+ * @param root The amCharts 5 `Root` instance.
+ * @param options Optional overrides for title, subtitle, and axis labels.
+ * @returns A {@link Maidr} object ready for ``.
+ *
+ * @throws If no supported chart is found inside the root.
+ */
+export function fromAmCharts(root: AmRoot, options?: AmChartsBinderOptions): Maidr {
+ const chart = findXYChart(root);
+ if (!chart) {
+ throw new Error(
+ 'maidr amCharts binder: no XYChart found in root.container. '
+ + 'Ensure the chart is fully initialized before calling fromAmCharts().',
+ );
+ }
+
+ return fromXYChart(chart, root.dom, options);
+}
+
+/**
+ * Convert an amCharts 5 {@link AmXYChart} directly into a MAIDR data object.
+ *
+ * Use this when you already hold a reference to the chart object.
+ *
+ * @param chart The amCharts 5 XY chart instance.
+ * @param containerEl The DOM element that contains the chart's rendered output.
+ * @param options Optional overrides.
+ */
+export function fromXYChart(
+ chart: AmXYChart,
+ containerEl: HTMLElement,
+ options?: AmChartsBinderOptions,
+): Maidr {
+ const title = options?.title ?? readChartTitle(chart);
+ const subtitle = options?.subtitle;
+ const xLabel = options?.axisLabels?.x ?? readAxisLabel(chart.xAxes.values[0], 'x');
+ const yLabel = options?.axisLabels?.y ?? readAxisLabel(chart.yAxes.values[0], 'y');
+
+ const layers: MaidrLayer[] = [];
+ const lineLayers: LinePoint[][] = [];
+ let lineLayerSelectors: string[] | undefined;
+ const lineSeriesNames: string[] = [];
+
+ // Collect bar series for grouped handling (stacked/dodged/normalized).
+ const barSeriesList: AmXYSeries[] = [];
+
+ for (const series of chart.series.values) {
+ const kind = classifySeriesKind(series);
+
+ switch (kind) {
+ case 'bar': {
+ barSeriesList.push(series);
+ break;
+ }
+ case 'histogram': {
+ const data = extractHistogramPoints(series);
+ if (data.length === 0)
+ break;
+ layers.push(buildHistogramLayer(series, data, xLabel, yLabel, containerEl));
+ break;
+ }
+ case 'heatmap': {
+ const data = extractHeatmapData(series);
+ if (!data)
+ break;
+ layers.push(buildHeatmapLayer(data, xLabel, yLabel));
+ break;
+ }
+ case 'line': {
+ const points = extractLinePoints(series);
+ if (points.length === 0)
+ break;
+ lineLayers.push(points);
+
+ const name = seriesName(series);
+ if (name)
+ lineSeriesNames.push(name);
+
+ const sel = buildLineSelector(series, containerEl);
+ if (sel) {
+ lineLayerSelectors ??= [];
+ lineLayerSelectors.push(sel);
+ }
+ break;
+ }
+ case 'scatter': {
+ const data = extractScatterPoints(series);
+ if (data.length === 0)
+ break;
+ layers.push(buildScatterLayer(series, data, xLabel, yLabel, containerEl));
+ break;
+ }
+ case 'candlestick': {
+ const data = extractCandlestickPoints(series);
+ if (data.length === 0)
+ break;
+ layers.push(buildCandlestickLayer(series, data, xLabel, yLabel));
+ break;
+ }
+ default:
+ // Skip unsupported series types.
+ break;
+ }
+ }
+
+ // Process bar series: single → BAR, multiple → STACKED/DODGED/NORMALIZED.
+ if (barSeriesList.length === 1) {
+ const series = barSeriesList[0];
+ const data = extractBarPoints(series);
+ if (data.length > 0) {
+ layers.push(buildBarLayer(series, data, xLabel, yLabel, containerEl));
+ }
+ } else if (barSeriesList.length > 1) {
+ const layer = buildSegmentedLayer(barSeriesList, chart, xLabel, yLabel, containerEl);
+ if (layer) {
+ layers.push(layer);
+ }
+ }
+
+ // Merge all line series into a single multi-line layer.
+ if (lineLayers.length > 0) {
+ const lineTitle = lineSeriesNames.length > 0
+ ? lineSeriesNames.join(', ')
+ : undefined;
+ layers.push(buildLineLayer(lineLayers, lineLayerSelectors, xLabel, yLabel, lineTitle));
+ }
+
+ const id = `amcharts-${containerEl.id || uid()}`;
+
+ const subplot: MaidrSubplot = { layers };
+
+ return {
+ id,
+ title,
+ subtitle,
+ subplots: [[subplot]],
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Layer builders
+// ---------------------------------------------------------------------------
+
+function buildBarLayer(
+ series: AmXYSeries,
+ data: BarPoint[],
+ xLabel: string,
+ yLabel: string,
+ containerEl: HTMLElement,
+): MaidrLayer {
+ const isHorizontal = typeof series.get('categoryYField') === 'string';
+ const selector = buildColumnSelector(series, containerEl);
+
+ return {
+ id: layerId(series),
+ type: TraceType.BAR,
+ title: seriesName(series),
+ ...(selector ? { selectors: selector } : {}),
+ ...(isHorizontal ? { orientation: Orientation.HORIZONTAL } : {}),
+ axes: { x: xLabel, y: yLabel },
+ data,
+ };
+}
+
+function buildSegmentedLayer(
+ barSeriesList: AmXYSeries[],
+ chart: AmXYChart,
+ xLabel: string,
+ yLabel: string,
+ containerEl: HTMLElement,
+): MaidrLayer | null {
+ const stackMode = detectStackMode(chart);
+
+ let traceType: TraceType;
+ switch (stackMode) {
+ case 'normal':
+ traceType = TraceType.STACKED;
+ break;
+ case '100%':
+ traceType = TraceType.NORMALIZED;
+ break;
+ default:
+ traceType = TraceType.DODGED;
+ }
+
+ // Each series becomes one group (row) in the SegmentedPoint[][] grid.
+ const data: SegmentedPoint[][] = [];
+ const selectorParts: string[] = [];
+
+ for (const series of barSeriesList) {
+ const points = extractSegmentedPoints(series);
+ if (points.length > 0) {
+ data.push(points);
+ const sel = buildColumnSelector(series, containerEl);
+ if (sel)
+ selectorParts.push(sel);
+ }
+ }
+
+ if (data.length === 0)
+ return null;
+
+ const isHorizontal = typeof barSeriesList[0].get('categoryYField') === 'string';
+ const combinedSelector = selectorParts.length > 0
+ ? selectorParts.join(', ')
+ : undefined;
+
+ return {
+ id: `segmented-${uid()}`,
+ type: traceType,
+ ...(combinedSelector ? { selectors: combinedSelector } : {}),
+ ...(isHorizontal ? { orientation: Orientation.HORIZONTAL } : {}),
+ axes: { x: xLabel, y: yLabel },
+ data,
+ };
+}
+
+function buildHistogramLayer(
+ series: AmXYSeries,
+ data: HistogramPoint[],
+ xLabel: string,
+ yLabel: string,
+ containerEl: HTMLElement,
+): MaidrLayer {
+ const selector = buildColumnSelector(series, containerEl);
+
+ return {
+ id: layerId(series),
+ type: TraceType.HISTOGRAM,
+ title: seriesName(series),
+ ...(selector ? { selectors: selector } : {}),
+ axes: { x: xLabel, y: yLabel },
+ data,
+ };
+}
+
+function buildHeatmapLayer(
+ data: HeatmapData,
+ xLabel: string,
+ yLabel: string,
+): MaidrLayer {
+ return {
+ id: `heatmap-${uid()}`,
+ type: TraceType.HEATMAP,
+ axes: { x: xLabel, y: yLabel },
+ data,
+ };
+}
+
+function buildLineLayer(
+ data: LinePoint[][],
+ selectors: string[] | undefined,
+ xLabel: string,
+ yLabel: string,
+ title?: string,
+): MaidrLayer {
+ return {
+ id: `line-${uid()}`,
+ type: TraceType.LINE,
+ ...(title ? { title } : {}),
+ ...(selectors && selectors.length > 0 ? { selectors } : {}),
+ axes: { x: xLabel, y: yLabel },
+ data,
+ };
+}
+
+function buildScatterLayer(
+ series: AmXYSeries,
+ data: ScatterPoint[],
+ xLabel: string,
+ yLabel: string,
+ containerEl: HTMLElement,
+): MaidrLayer {
+ const selector = buildScatterSelector(series, containerEl);
+
+ return {
+ id: layerId(series),
+ type: TraceType.SCATTER,
+ title: seriesName(series),
+ ...(selector ? { selectors: selector } : {}),
+ axes: { x: xLabel, y: yLabel },
+ data,
+ };
+}
+
+function buildCandlestickLayer(
+ series: AmXYSeries,
+ data: CandlestickPoint[],
+ xLabel: string,
+ yLabel: string,
+): MaidrLayer {
+ return {
+ id: layerId(series),
+ type: TraceType.CANDLESTICK,
+ title: seriesName(series),
+ axes: { x: xLabel, y: yLabel },
+ data,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function findXYChart(root: AmRoot): AmXYChart | undefined {
+ for (const child of root.container.children.values) {
+ // Duck-type check: XYChart has series, xAxes, yAxes.
+ const c = child as Partial;
+ if (c.series && c.xAxes && c.yAxes) {
+ return c as AmXYChart;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Detect the stacking mode from the chart's value axes.
+ *
+ * In amCharts 5, stacking is controlled by setting `stackMode` on a
+ * `ValueAxis`: `"normal"` for stacked, `"100%"` for normalized.
+ */
+function detectStackMode(chart: AmXYChart): 'none' | 'normal' | '100%' {
+ for (const axis of chart.yAxes.values) {
+ const mode = axis.get('stackMode');
+ if (mode === 'normal' || mode === '100%')
+ return mode;
+ }
+ for (const axis of chart.xAxes.values) {
+ const mode = axis.get('stackMode');
+ if (mode === 'normal' || mode === '100%')
+ return mode;
+ }
+ return 'none';
+}
+
+function readChartTitle(chart: AmXYChart): string | undefined {
+ // amCharts 5 titles are typically children of the chart.
+ // A title entity has className "Label" or "Title" and a text property.
+ if (!('children' in chart))
+ return undefined;
+
+ const children = (chart as unknown as Record).children;
+ if (children == null || typeof children !== 'object')
+ return undefined;
+
+ const values = (children as Record).values;
+ if (!Array.isArray(values))
+ return undefined;
+
+ for (const child of values) {
+ if (child == null || typeof child !== 'object')
+ continue;
+ const c = child as Record;
+ if (c.className === 'Label' || c.className === 'Title') {
+ if (typeof c.get === 'function') {
+ const text = (c as { get: (k: string) => unknown }).get('text');
+ if (typeof text === 'string' && text.length > 0)
+ return text;
+ }
+ }
+ }
+ return undefined;
+}
+
+function seriesName(series: AmXYSeries): string | undefined {
+ const name = series.get('name');
+ return typeof name === 'string' && name.length > 0 ? name : undefined;
+}
+
+function layerId(series: AmXYSeries): string {
+ return `amcharts-series-${series.uid ?? counter()}`;
+}
+
+/**
+ * Monotonically increasing counter used as a fallback when no deterministic
+ * ID (e.g. container id, series uid) is available.
+ *
+ * IDs produced by this counter are ephemeral — they are not stable across
+ * page loads or hot reloads and must not be persisted.
+ */
+let _counter = 0;
+function counter(): string {
+ return String(++_counter);
+}
+
+/**
+ * Produce a short identifier for a generated layer.
+ * Prefers the container's DOM `id`; falls back to the monotonic counter.
+ */
+function uid(): string {
+ return counter();
+}
diff --git a/src/binder/amcharts/extractors.ts b/src/binder/amcharts/extractors.ts
new file mode 100644
index 00000000..75465e59
--- /dev/null
+++ b/src/binder/amcharts/extractors.ts
@@ -0,0 +1,447 @@
+/**
+ * Data extraction functions that convert amCharts 5 series data
+ * into MAIDR-compatible data point arrays.
+ */
+
+import type {
+ BarPoint,
+ CandlestickPoint,
+ CandlestickTrend,
+ HeatmapData,
+ HistogramPoint,
+ LinePoint,
+ ScatterPoint,
+ SegmentedPoint,
+} from '@type/grammar';
+import type { AmAxis, AmDataItem, AmXYSeries } from './types';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Read a string label from an axis, falling back to `"x"` / `"y"`.
+ */
+export function readAxisLabel(axis: AmAxis | undefined, fallback: string): string {
+ if (!axis)
+ return fallback;
+
+ const titleEntity = axis.get('title');
+ if (titleEntity != null && typeof (titleEntity as Record).get === 'function') {
+ const text = (titleEntity as { get: (k: string) => unknown }).get('text');
+ if (typeof text === 'string' && text.length > 0)
+ return text;
+ }
+
+ const name = axis.get('name');
+ if (typeof name === 'string' && name.length > 0)
+ return name;
+ return fallback;
+}
+
+/**
+ * Determine whether a series is category-based (bar/column) vs. value-based (scatter).
+ */
+function hasCategoryX(series: AmXYSeries): boolean {
+ return typeof series.get('categoryXField') === 'string';
+}
+
+function hasCategoryY(series: AmXYSeries): boolean {
+ return typeof series.get('categoryYField') === 'string';
+}
+
+// ---------------------------------------------------------------------------
+// Column / Bar extraction
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract {@link BarPoint} data from a column or bar series.
+ */
+export function extractBarPoints(series: AmXYSeries): BarPoint[] {
+ const points: BarPoint[] = [];
+
+ const isHorizontal = hasCategoryY(series);
+ const categoryField = isHorizontal ? 'categoryY' : 'categoryX';
+ const valueField = isHorizontal ? 'valueX' : 'valueY';
+
+ for (const item of series.dataItems) {
+ const category = item.get(categoryField);
+ const value = item.get(valueField);
+
+ if (category == null || value == null)
+ continue;
+
+ const numValue = toNumber(value);
+ if (numValue == null)
+ continue;
+
+ points.push({
+ x: isHorizontal ? numValue : toStringOrNumber(category),
+ y: isHorizontal ? toStringOrNumber(category) : numValue,
+ });
+ }
+
+ return points;
+}
+
+// ---------------------------------------------------------------------------
+// Segmented bar extraction (stacked / dodged / normalized)
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract {@link SegmentedPoint} data from a single column series that is
+ * part of a multi-series (segmented) bar chart.
+ *
+ * The series name is used as the `fill` group identifier — this follows the
+ * ggplot2 convention where `fill` maps a variable to grouped visual encoding.
+ */
+export function extractSegmentedPoints(series: AmXYSeries): SegmentedPoint[] {
+ const points: SegmentedPoint[] = [];
+ const fill = (series.get('name') as string | undefined) ?? '';
+
+ const isHorizontal = hasCategoryY(series);
+ const categoryField = isHorizontal ? 'categoryY' : 'categoryX';
+ const valueField = isHorizontal ? 'valueX' : 'valueY';
+
+ for (const item of series.dataItems) {
+ const category = item.get(categoryField);
+ const value = item.get(valueField);
+
+ if (category == null || value == null)
+ continue;
+
+ const numValue = toNumber(value);
+ if (numValue == null)
+ continue;
+
+ points.push({
+ x: isHorizontal ? numValue : toStringOrNumber(category),
+ y: isHorizontal ? toStringOrNumber(category) : numValue,
+ fill,
+ });
+ }
+
+ return points;
+}
+
+// ---------------------------------------------------------------------------
+// Histogram extraction
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract {@link HistogramPoint} data from a column series that represents
+ * a histogram (value-based X axis with openValueX for bin edges).
+ */
+export function extractHistogramPoints(series: AmXYSeries): HistogramPoint[] {
+ const points: HistogramPoint[] = [];
+
+ for (const item of series.dataItems) {
+ const valueX = item.get('valueX');
+ const openValueX = item.get('openValueX');
+ const valueY = item.get('valueY');
+
+ if (valueX == null || valueY == null)
+ continue;
+
+ const xEnd = toNumber(valueX);
+ const y = toNumber(valueY);
+ if (xEnd == null || y == null)
+ continue;
+
+ const xStart = openValueX != null ? (toNumber(openValueX) ?? xEnd) : xEnd;
+
+ const xMin = Math.min(xStart, xEnd);
+ const xMax = Math.max(xStart, xEnd);
+
+ points.push({
+ x: (xMin + xMax) / 2,
+ y,
+ xMin,
+ xMax,
+ yMin: 0,
+ yMax: y,
+ });
+ }
+
+ return points;
+}
+
+// ---------------------------------------------------------------------------
+// Heatmap extraction
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract {@link HeatmapData} from a column series that uses two category
+ * axes (categoryX and categoryY) to form a 2D grid.
+ *
+ * The heat value is read from `value`, `valueY`, or `valueX` data fields.
+ */
+export function extractHeatmapData(series: AmXYSeries): HeatmapData | null {
+ const xLabels: string[] = [];
+ const yLabels: string[] = [];
+ const xIndex = new Map();
+ const yIndex = new Map();
+ const valueMap = new Map();
+
+ for (const item of series.dataItems) {
+ const catX = item.get('categoryX');
+ const catY = item.get('categoryY');
+ const value = readHeatmapValue(item);
+
+ if (catX == null || catY == null || value == null)
+ continue;
+
+ const x = String(catX);
+ const y = String(catY);
+
+ if (!xIndex.has(x)) {
+ xIndex.set(x, xLabels.length);
+ xLabels.push(x);
+ }
+ if (!yIndex.has(y)) {
+ yIndex.set(y, yLabels.length);
+ yLabels.push(y);
+ }
+
+ valueMap.set(`${xIndex.get(x)},${yIndex.get(y)}`, value);
+ }
+
+ if (xLabels.length === 0 || yLabels.length === 0)
+ return null;
+
+ // Build 2D points grid: points[yIdx][xIdx]
+ const points: number[][] = yLabels.map((_, yi) =>
+ xLabels.map((_, xi) => valueMap.get(`${xi},${yi}`) ?? 0),
+ );
+
+ return { x: xLabels, y: yLabels, points };
+}
+
+/**
+ * Read the numeric heat value from a data item, trying multiple common fields.
+ */
+function readHeatmapValue(item: AmDataItem): number | null {
+ for (const key of ['value', 'valueY', 'valueX']) {
+ const val = item.get(key);
+ if (val != null) {
+ const n = Number(val);
+ if (Number.isFinite(n))
+ return n;
+ }
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Line extraction
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract {@link LinePoint} data from a single line series.
+ * Returns a flat array of points for one series. The adapter aggregates
+ * multiple series into the 2D array (`LinePoint[][]`) that MAIDR expects.
+ */
+export function extractLinePoints(series: AmXYSeries): LinePoint[] {
+ const seriesName = series.get('name') as string | undefined;
+ const points: LinePoint[] = [];
+
+ for (const item of series.dataItems) {
+ const x = readXValue(item, series);
+ const y = item.get('valueY');
+
+ if (x == null || y == null)
+ continue;
+
+ const yNum = toNumber(y);
+ if (yNum == null)
+ continue;
+
+ const point: LinePoint = { x: toStringOrNumber(x), y: yNum };
+ if (seriesName)
+ point.fill = seriesName;
+ points.push(point);
+ }
+
+ return points;
+}
+
+// ---------------------------------------------------------------------------
+// Scatter extraction
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract {@link ScatterPoint} data from a value-value (scatter) series.
+ */
+export function extractScatterPoints(series: AmXYSeries): ScatterPoint[] {
+ const points: ScatterPoint[] = [];
+
+ for (const item of series.dataItems) {
+ const x = item.get('valueX');
+ const y = item.get('valueY');
+
+ if (x == null || y == null)
+ continue;
+
+ const xNum = toNumber(x);
+ const yNum = toNumber(y);
+ if (xNum == null || yNum == null)
+ continue;
+
+ points.push({ x: xNum, y: yNum });
+ }
+
+ return points;
+}
+
+// ---------------------------------------------------------------------------
+// Candlestick extraction
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract {@link CandlestickPoint} data from a candlestick series.
+ */
+export function extractCandlestickPoints(series: AmXYSeries): CandlestickPoint[] {
+ const points: CandlestickPoint[] = [];
+
+ for (const item of series.dataItems) {
+ const label = readXValue(item, series);
+ const open = item.get('openValueY');
+ const high = item.get('highValueY');
+ const low = item.get('lowValueY');
+ const close = item.get('valueY');
+ const volume = item.get('valueX'); // volume sometimes on X
+
+ if (open == null || close == null)
+ continue;
+
+ const openNum = toNumber(open);
+ const closeNum = toNumber(close);
+ if (openNum == null || closeNum == null)
+ continue;
+
+ const highNum = (high != null ? toNumber(high) : null) ?? Math.max(openNum, closeNum);
+ const lowNum = (low != null ? toNumber(low) : null) ?? Math.min(openNum, closeNum);
+
+ let trend: CandlestickTrend = 'Neutral';
+ if (closeNum > openNum)
+ trend = 'Bull';
+ else if (closeNum < openNum)
+ trend = 'Bear';
+
+ points.push({
+ value: label != null ? String(label) : '',
+ open: openNum,
+ high: highNum,
+ low: lowNum,
+ close: closeNum,
+ volume: (volume != null ? toNumber(volume) : null) ?? 0,
+ trend,
+ volatility: highNum - lowNum,
+ });
+ }
+
+ return points;
+}
+
+// ---------------------------------------------------------------------------
+// Series type detection
+// ---------------------------------------------------------------------------
+
+/** Recognized amCharts 5 series class names. */
+const COLUMN_CLASSES = new Set([
+ 'ColumnSeries',
+ 'CurvedColumnSeries',
+]);
+
+const LINE_CLASSES = new Set([
+ 'LineSeries',
+ 'SmoothedXLineSeries',
+ 'SmoothedYLineSeries',
+ 'SmoothedXYLineSeries',
+ 'StepLineSeries',
+]);
+
+const CANDLESTICK_CLASSES = new Set([
+ 'CandlestickSeries',
+ 'OHLCSeries',
+]);
+
+export type SeriesKind = 'bar' | 'line' | 'scatter' | 'candlestick' | 'histogram' | 'heatmap' | 'unknown';
+
+/**
+ * Determine the MAIDR trace kind for a given amCharts series.
+ */
+export function classifySeriesKind(series: AmXYSeries): SeriesKind {
+ const className = series.className ?? '';
+
+ if (CANDLESTICK_CLASSES.has(className))
+ return 'candlestick';
+
+ if (COLUMN_CLASSES.has(className)) {
+ // Heatmap: both category X and category Y fields.
+ if (hasCategoryX(series) && hasCategoryY(series))
+ return 'heatmap';
+
+ // Histogram: value-based X axis with openValueX bin edges.
+ if (!hasCategoryX(series) && !hasCategoryY(series)
+ && typeof series.get('openValueXField') === 'string') {
+ return 'histogram';
+ }
+
+ return 'bar';
+ }
+
+ if (LINE_CLASSES.has(className)) {
+ // A "line" series with value-only axes (no category) is still a line in MAIDR.
+ return 'line';
+ }
+
+ // Fallback heuristic: if both X and Y are value fields → scatter.
+ if (!hasCategoryX(series) && !hasCategoryY(series))
+ return 'scatter';
+
+ // Default to bar for category-based series.
+ return 'bar';
+}
+
+// ---------------------------------------------------------------------------
+// Utilities
+// ---------------------------------------------------------------------------
+
+function readXValue(item: AmDataItem, series: AmXYSeries): unknown {
+ // Try category first, then numeric value.
+ const cat = item.get('categoryX');
+ if (cat != null)
+ return cat;
+ const val = item.get('valueX');
+ if (val != null)
+ return val;
+
+ // Date axis: amCharts stores Date objects.
+ const dateX = item.get('dateX');
+ if (dateX instanceof Date)
+ return dateX.toISOString();
+
+ // Try reading from the category field name.
+ const fieldName = series.get('categoryXField') as string | undefined;
+ if (fieldName)
+ return item.get(fieldName);
+
+ return undefined;
+}
+
+/**
+ * Convert an unknown value to a finite number, or `null` if the
+ * conversion is not possible. Callers should skip data items that
+ * return `null` to avoid silent data corruption.
+ */
+function toNumber(value: unknown): number | null {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : null;
+}
+
+function toStringOrNumber(value: unknown): string | number {
+ if (typeof value === 'number' && Number.isFinite(value))
+ return value;
+ return String(value ?? '');
+}
diff --git a/src/binder/amcharts/index.ts b/src/binder/amcharts/index.ts
new file mode 100644
index 00000000..e584ff80
--- /dev/null
+++ b/src/binder/amcharts/index.ts
@@ -0,0 +1,10 @@
+/**
+ * amCharts 5 binder for MAIDR.
+ *
+ * Provides functions to convert amCharts 5 chart instances into MAIDR data
+ * objects that can be passed to the `` React component or the
+ * `maidr-data` HTML attribute.
+ */
+
+export { fromAmCharts, fromXYChart } from './adapter';
+export type { AmChartsBinderOptions, AmRoot, AmXYChart, AmXYSeries } from './types';
diff --git a/src/binder/amcharts/selectors.ts b/src/binder/amcharts/selectors.ts
new file mode 100644
index 00000000..b643ea63
--- /dev/null
+++ b/src/binder/amcharts/selectors.ts
@@ -0,0 +1,184 @@
+/**
+ * CSS selector generation for amCharts 5 SVG elements.
+ *
+ * amCharts 5 uses a Canvas-based renderer by default. When SVG output is
+ * available (e.g. exported SVG or a custom SVG renderer), this module
+ * attempts to build CSS selectors that map each data point to its
+ * corresponding SVG element so MAIDR can highlight it during navigation.
+ *
+ * If the chart container does not contain queryable SVG children, the
+ * functions here return `undefined` and MAIDR will simply skip visual
+ * highlighting for that layer.
+ */
+
+import type { AmXYSeries } from './types';
+
+/**
+ * Escape a string for use in a CSS selector.
+ * Uses the native `CSS.escape` when available (browsers), otherwise falls
+ * back to a minimal replacement that covers the most common special chars.
+ */
+function cssEscape(value: string): string {
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
+ return CSS.escape(value);
+ }
+ // Minimal fallback for Node.js / test environments.
+ return value.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
+}
+
+/**
+ * Attempt to build a CSS selector string for the SVG elements of a
+ * column (bar) series.
+ *
+ * amCharts 5 column series expose individual column sprites that may
+ * reference real DOM nodes when rendering to SVG/Canvas with accessible
+ * DOM overlays.
+ *
+ * @returns A CSS selector string, or `undefined` if no SVG elements
+ * are found.
+ */
+export function buildColumnSelector(
+ series: AmXYSeries,
+ containerEl: HTMLElement,
+): string | undefined {
+ // Strategy 1: series.columns contains sprites with .dom references.
+ const columns = series.columns;
+ if (columns && columns.values.length > 0) {
+ const first = columns.values[0];
+ if (first?.dom) {
+ return buildSelectorFromSprites(columns.values, containerEl);
+ }
+ }
+
+ // Strategy 2: look for rect or path elements inside an amCharts
+ // series group identified by a data attribute or role.
+ const uid = series.uid;
+ if (uid != null) {
+ const candidate = `[data-am-id="${uid}"] rect, [data-am-id="${uid}"] path`;
+ if (containerEl.querySelector(candidate))
+ return candidate;
+ }
+
+ return undefined;
+}
+
+/**
+ * Attempt to build a CSS selector string for a line series.
+ *
+ * Line series typically render a single `` for the stroke.
+ * MAIDR line traces use an array of selectors (one per line group),
+ * but individual point highlighting relies on bullets.
+ */
+export function buildLineSelector(
+ series: AmXYSeries,
+ containerEl: HTMLElement,
+): string | undefined {
+ // Try bullet sprites first (for individual point highlighting).
+ const bullets = series.bullets;
+ if (bullets && bullets.values.length > 0) {
+ const first = bullets.values[0];
+ const sprite = first?.sprite;
+ if (sprite?.dom) {
+ return buildSelectorFromBullets(bullets.values, containerEl);
+ }
+ }
+
+ // Fallback: try the stroke path.
+ const strokes = series.strokes;
+ if (strokes && strokes.values.length > 0) {
+ const first = strokes.values[0];
+ if (first?.dom) {
+ return selectorForElement(first.dom, containerEl);
+ }
+ }
+
+ return undefined;
+}
+
+/**
+ * Attempt to build a CSS selector string for scatter (bullet) series.
+ */
+export function buildScatterSelector(
+ series: AmXYSeries,
+ containerEl: HTMLElement,
+): string | undefined {
+ return buildLineSelector(series, containerEl);
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Build a single CSS selector that matches all sprite DOM elements
+ * using a shared parent + tag strategy.
+ */
+function buildSelectorFromSprites(
+ sprites: Array<{ dom?: SVGElement }>,
+ container: HTMLElement,
+): string | undefined {
+ const doms = sprites.map(s => s.dom).filter(Boolean) as SVGElement[];
+ if (doms.length === 0)
+ return undefined;
+ return buildCommonSelector(doms, container);
+}
+
+function buildSelectorFromBullets(
+ bullets: Array<{ sprite?: { dom?: SVGElement } }>,
+ container: HTMLElement,
+): string | undefined {
+ const doms = bullets
+ .map(b => b.sprite?.dom)
+ .filter(Boolean) as SVGElement[];
+ if (doms.length === 0)
+ return undefined;
+ return buildCommonSelector(doms, container);
+}
+
+/**
+ * Given a set of SVG elements, find a common parent and produce a
+ * CSS selector that matches exactly those elements.
+ */
+function buildCommonSelector(
+ elements: SVGElement[],
+ _container: HTMLElement,
+): string | undefined {
+ if (elements.length === 0)
+ return undefined;
+
+ const first = elements[0];
+ const parent = first.parentElement;
+ if (!parent)
+ return undefined;
+
+ // Use the parent's id if available.
+ const parentId = parent.id || parent.getAttribute('data-am-id');
+ const tag = first.tagName.toLowerCase();
+
+ if (parentId) {
+ return `#${cssEscape(parentId)} > ${tag}`;
+ }
+
+ // Fallback: use the element's tag plus class.
+ const cls = first.getAttribute('class');
+ if (cls) {
+ return `${tag}.${cssEscape(cls.split(' ')[0])}`;
+ }
+
+ return undefined;
+}
+
+/**
+ * Build a selector for a single SVG element.
+ */
+function selectorForElement(
+ el: SVGElement,
+ _container: HTMLElement,
+): string | undefined {
+ if (el.id)
+ return `#${cssEscape(el.id)}`;
+ const cls = el.getAttribute('class');
+ if (cls)
+ return `${el.tagName.toLowerCase()}.${cssEscape(cls.split(' ')[0])}`;
+ return undefined;
+}
diff --git a/src/binder/amcharts/types.ts b/src/binder/amcharts/types.ts
new file mode 100644
index 00000000..a44261e2
--- /dev/null
+++ b/src/binder/amcharts/types.ts
@@ -0,0 +1,122 @@
+/**
+ * Duck-typed interfaces for amCharts 5 objects.
+ *
+ * These interfaces define the minimal surface area of the amCharts 5 API
+ * that the MAIDR binder needs. They use duck typing so consumers do not
+ * need to import amCharts types directly — any object that structurally
+ * matches will work.
+ *
+ * @remarks
+ * Targets amCharts 5. amCharts 4 has a significantly different API and
+ * is not supported by this binder.
+ */
+
+/**
+ * Minimal interface for `am5.Root`.
+ */
+export interface AmRoot {
+ dom: HTMLElement;
+ container: AmContainer;
+}
+
+/**
+ * Minimal interface for an amCharts 5 container (e.g. `root.container`).
+ */
+export interface AmContainer {
+ children: AmListLike;
+}
+
+/**
+ * Minimal interface for amCharts 5 list-like collections
+ * (e.g. `chart.series`, `chart.xAxes`).
+ */
+export interface AmListLike {
+ values: T[];
+}
+
+/**
+ * Any amCharts 5 entity that supports `.get()` property access.
+ */
+export interface AmEntity {
+ get: (key: string) => unknown;
+ className?: string;
+ uid?: number;
+}
+
+/**
+ * Minimal interface for an amCharts 5 XY chart.
+ */
+export interface AmXYChart extends AmEntity {
+ series: AmListLike;
+ xAxes: AmListLike;
+ yAxes: AmListLike;
+}
+
+/**
+ * Minimal interface for an amCharts 5 XY series
+ * (ColumnSeries, LineSeries, CandlestickSeries, etc.).
+ */
+export interface AmXYSeries extends AmEntity {
+ dataItems: AmDataItem[];
+ columns?: AmListLike;
+ bullets?: AmListLike;
+ strokes?: AmListLike;
+}
+
+/**
+ * Minimal interface for an amCharts 5 axis.
+ */
+export interface AmAxis extends AmEntity {
+ dataItems: AmDataItem[];
+}
+
+/**
+ * Minimal interface for an amCharts 5 data item.
+ */
+export interface AmDataItem {
+ get: (key: string) => unknown;
+ uid?: number;
+ bullets?: AmBullet[];
+}
+
+/**
+ * Minimal interface for an amCharts 5 bullet (used for scatter points).
+ */
+export interface AmBullet {
+ get: (key: string) => unknown;
+ sprite?: AmSprite;
+}
+
+/**
+ * Minimal interface for an amCharts 5 visual sprite / graphic.
+ */
+export interface AmSprite {
+ dom?: SVGElement;
+ uid?: number;
+}
+
+// ---------------------------------------------------------------------------
+// Options
+// ---------------------------------------------------------------------------
+
+/**
+ * Options for the amCharts-to-MAIDR adapter.
+ */
+export interface AmChartsBinderOptions {
+ /**
+ * Override the chart title. By default the binder reads the chart's
+ * first title child if one exists.
+ */
+ title?: string;
+
+ /**
+ * Override the chart subtitle.
+ */
+ subtitle?: string;
+
+ /**
+ * Override individual axis labels.
+ * Keys are `"x"` or `"y"`.
+ */
+ axisLabels?: { x?: string; y?: string };
+}
diff --git a/vite.amcharts.config.ts b/vite.amcharts.config.ts
new file mode 100644
index 00000000..9a57979d
--- /dev/null
+++ b/vite.amcharts.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/amcharts-entry.ts'),
+ formats: ['es'],
+ fileName: () => 'amcharts.mjs',
+ },
+ sourcemap: true,
+ outDir: 'dist',
+ emptyOutDir: false,
+ rollupOptions: {
+ onwarn(warning, warn) {
+ if (warning.code === 'MODULE_LEVEL_DIRECTIVE') {
+ return;
+ }
+ warn(warning);
+ },
+ },
+ },
+ define: {
+ 'process.env': {},
+ },
+ 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'),
+ },
+ },
+});