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'), + }, + }, +});