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..f95e27ca 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,11 @@ "types": "./dist/react.d.mts", "import": "./dist/react.mjs", "default": "./dist/react.mjs" + }, + "./recharts": { + "types": "./dist/recharts.d.mts", + "import": "./dist/recharts.mjs", + "default": "./dist/recharts.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.recharts.config.ts", "build:script": "vite build", "build:react": "vite build --config vite.react.config.ts", + "build:recharts": "vite build --config vite.recharts.config.ts", "prepublishOnly": "npm run build", "prepare": "husky", "commitlint": "commitlint --from=HEAD~1 --to=HEAD", @@ -42,7 +48,8 @@ }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "react-dom": "^18.0.0 || ^19.0.0", + "recharts": "^2.0.0 || ^3.0.0" }, "peerDependenciesMeta": { "react": { @@ -50,6 +57,9 @@ }, "react-dom": { "optional": true + }, + "recharts": { + "optional": true } }, "dependencies": { diff --git a/src/adapters/recharts/MaidrRecharts.tsx b/src/adapters/recharts/MaidrRecharts.tsx new file mode 100644 index 00000000..8045a344 --- /dev/null +++ b/src/adapters/recharts/MaidrRecharts.tsx @@ -0,0 +1,99 @@ +/** + * Convenience wrapper component that combines Recharts adapter + Maidr component. + * + * Accepts Recharts-style configuration props and automatically converts + * the data into MAIDR format, wrapping the children with the `` component. + * + * @example + * ```tsx + * import { MaidrRecharts } from 'maidr/recharts'; + * import { BarChart, Bar, XAxis, YAxis } from 'recharts'; + * + * function AccessibleBarChart() { + * const data = [ + * { name: 'Q1', revenue: 100 }, + * { name: 'Q2', revenue: 200 }, + * { name: 'Q3', revenue: 150 }, + * ]; + * + * return ( + * + * + * + * + * + * + * + * ); + * } + * ``` + */ + +import type { JSX } from 'react'; +import type { MaidrRechartsProps } from './types'; +import { useMemo } from 'react'; +import { Maidr } from '../../maidr-component'; +import { convertRechartsToMaidr } from './converters'; + +/** + * Wrapper component that makes Recharts charts accessible via MAIDR. + * + * This component extracts data configuration from props, converts it to + * MAIDR's data format, and renders the Recharts children inside a `` + * component for audio sonification, text descriptions, braille output, + * and keyboard navigation. + */ +export function MaidrRecharts({ + id, + title, + subtitle, + caption, + data, + chartType, + xKey, + yKeys, + layers, + xLabel, + yLabel, + orientation, + fillKeys, + binConfig, + selectorOverride, + children, +}: MaidrRechartsProps): JSX.Element { + const maidrData = useMemo( + () => convertRechartsToMaidr({ + id, + title, + subtitle, + caption, + data, + chartType, + xKey, + yKeys, + layers, + xLabel, + yLabel, + orientation, + fillKeys, + binConfig, + selectorOverride, + }), + [id, title, subtitle, caption, data, chartType, xKey, yKeys, layers, xLabel, yLabel, orientation, fillKeys, binConfig, selectorOverride], + ); + + return ( + + {children} + + ); +} diff --git a/src/adapters/recharts/converters.ts b/src/adapters/recharts/converters.ts new file mode 100644 index 00000000..5e090aaa --- /dev/null +++ b/src/adapters/recharts/converters.ts @@ -0,0 +1,452 @@ +/** + * Data converters for transforming Recharts data format into MAIDR's schema. + * + * Recharts uses a flat array of objects where each object represents one + * data point with named fields: + * [{ name: 'Q1', revenue: 100, cost: 50 }, ...] + * + * MAIDR uses typed data structures per chart type: + * BarPoint[] = [{ x, y }, ...] + * LinePoint[][] = [[{ x, y, fill? }, ...], ...] + * ScatterPoint[] = [{ x, y }, ...] + * SegmentedPoint[][] = [[{ x, y, fill }, ...], ...] (stacked/dodged/normalized) + * HistogramPoint[] = [{ x, y, xMin, xMax, yMin, yMax }, ...] + */ + +import type { + BarPoint, + HistogramPoint, + LinePoint, + Maidr, + MaidrLayer, + MaidrSubplot, + ScatterPoint, + SegmentedPoint, +} from '@type/grammar'; +import type { RechartsAdapterConfig, RechartsChartType, RechartsLayerConfig } from './types'; +import { Orientation, TraceType } from '@type/grammar'; +import { getRechartsSelector } from './selectors'; + +/** + * Converts a Recharts adapter config into MAIDR's root data structure. + * + * @param config - Recharts adapter configuration + * @returns MaidrData ready to pass to the `` component + */ +export function convertRechartsToMaidr(config: RechartsAdapterConfig): Maidr { + const layers = buildLayers(config); + + const subplot: MaidrSubplot = { + layers, + }; + + return { + id: config.id, + title: config.title, + subtitle: config.subtitle, + caption: config.caption, + subplots: [[subplot]], + }; +} + +/** + * Builds MAIDR layers from the adapter config. + * Handles both simple mode (chartType + yKeys) and composed mode (layers). + */ +function buildLayers(config: RechartsAdapterConfig): MaidrLayer[] { + if (config.layers) { + return buildComposedLayers(config); + } + return buildSimpleLayers(config); +} + +/** + * Builds layers for simple mode (single chart type, one or more yKeys). + */ +function buildSimpleLayers(config: RechartsAdapterConfig): MaidrLayer[] { + const { data, chartType, xKey, yKeys, xLabel, yLabel, orientation, fillKeys, selectorOverride } = config; + + if (!chartType || !yKeys || yKeys.length === 0) { + throw new Error( + 'RechartsAdapter: either provide chartType + yKeys (simple mode) or layers (composed mode)', + ); + } + + const hasMultipleSeries = yKeys.length > 1; + + // Stacked/dodged/normalized bars with multiple series: + // produce a single layer with SegmentedPoint[][] data. + // With a single yKey, fall back to regular BAR. + if (isSegmentedBarType(chartType) && hasMultipleSeries) { + return [buildSegmentedBarLayer(data, xKey, yKeys, chartType, xLabel, yLabel, orientation, fillKeys, selectorOverride)]; + } + + // Histogram: produce HistogramPoint[] data + if (chartType === 'histogram') { + return [buildHistogramLayer(data, xKey, yKeys[0], chartType, xLabel, yLabel, orientation, config.binConfig, selectorOverride)]; + } + + // Line/area/radar with multiple series: single layer with 2D LinePoint[][] data + if (isLineType(chartType) && hasMultipleSeries) { + return [buildMultiSeriesLineLayer(data, xKey, yKeys, chartType, xLabel, yLabel, selectorOverride)]; + } + + // Determine the MAIDR trace type. For segmented bar types with a single yKey, + // fall back to BAR since a single series is not segmented. + const maidrType = (isSegmentedBarType(chartType) && !hasMultipleSeries) + ? TraceType.BAR + : toTraceType(chartType); + + // Simple single-series or multiple separate layers + return yKeys.map((yKey, index) => { + const seriesIndex = hasMultipleSeries ? index : undefined; + const selector = selectorOverride ?? getRechartsSelector(chartType, seriesIndex); + const layerData = convertData(chartType, data, xKey, yKey); + + return { + id: String(index), + type: maidrType, + title: hasMultipleSeries ? (fillKeys?.[index] ?? yKey) : undefined, + selectors: selector, + orientation: orientation ?? (isBarType(chartType) ? Orientation.VERTICAL : undefined), + axes: { + x: xLabel, + y: yLabel, + }, + data: layerData, + } as MaidrLayer; + }); +} + +/** + * Builds a single segmented bar layer (stacked/dodged/normalized) with SegmentedPoint[][] data. + * + * SegmentedPoint[][] layout: + * outer array = categories (x values) + * inner array = segments within each category (one per yKey/fill) + */ +function buildSegmentedBarLayer( + data: Record[], + xKey: string, + yKeys: string[], + chartType: RechartsChartType, + xLabel?: string, + yLabel?: string, + orientation?: Orientation, + fillKeys?: string[], + selectorOverride?: string, +): MaidrLayer { + const segmentedData: SegmentedPoint[][] = data.map((item) => { + return yKeys.map((yKey, i) => ({ + x: item[xKey] as string | number, + y: toNumber(item[yKey]), + fill: fillKeys?.[i] ?? yKey, + })); + }); + + const selector = selectorOverride ?? getRechartsSelector(chartType); + + return { + id: '0', + type: toTraceType(chartType), + selectors: selector, + orientation: orientation ?? Orientation.VERTICAL, + axes: { + x: xLabel, + y: yLabel, + fill: 'Series', + }, + data: segmentedData, + }; +} + +/** + * Builds a histogram layer with HistogramPoint[] data. + */ +function buildHistogramLayer( + data: Record[], + xKey: string, + yKey: string, + chartType: RechartsChartType, + xLabel?: string, + yLabel?: string, + orientation?: Orientation, + binConfig?: RechartsAdapterConfig['binConfig'], + selectorOverride?: string, +): MaidrLayer { + const histData: HistogramPoint[] = data.map((item) => { + const x = item[xKey] as string | number; + const y = toNumber(item[yKey]); + const xMin = binConfig ? toNumber(item[binConfig.xMinKey]) : 0; + const xMax = binConfig ? toNumber(item[binConfig.xMaxKey]) : 0; + const yMin = binConfig?.yMinKey ? toNumber(item[binConfig.yMinKey]) : 0; + const yMax = binConfig?.yMaxKey ? toNumber(item[binConfig.yMaxKey]) : y; + + return { x, y, xMin, xMax, yMin, yMax }; + }); + + const selector = selectorOverride ?? getRechartsSelector(chartType); + + return { + id: '0', + type: TraceType.HISTOGRAM, + selectors: selector, + orientation: orientation ?? Orientation.VERTICAL, + axes: { + x: xLabel, + y: yLabel, + }, + data: histData, + }; +} + +/** + * Builds a single line/area/radar layer with multi-series 2D data. + * + * X-axis values are preserved as-is (string or number) to avoid + * coercing category labels like 'Jan' to 0. + */ +function buildMultiSeriesLineLayer( + data: Record[], + xKey: string, + yKeys: string[], + chartType: RechartsChartType, + xLabel?: string, + yLabel?: string, + selectorOverride?: string, +): MaidrLayer { + const lineData: LinePoint[][] = yKeys.map(yKey => + data.map(item => ({ + x: toLineX(item[xKey]), + y: toNumber(item[yKey]), + fill: yKey, + })), + ); + + // Multi-series: CSS selectors are unreliable, so omit them unless the + // consumer provides a selectorOverride with custom class names. + const selectors = selectorOverride + ? yKeys.map(() => selectorOverride) + : undefined; + + return { + id: '0', + type: toTraceType(chartType), + selectors, + axes: { + x: xLabel, + y: yLabel, + }, + data: lineData, + }; +} + +/** + * Builds layers for composed mode (mixed chart types via layers config). + */ +function buildComposedLayers(config: RechartsAdapterConfig): MaidrLayer[] { + const { data, xKey, xLabel, yLabel, orientation, layers, selectorOverride } = config; + + if (!layers || layers.length === 0) { + throw new Error('RechartsAdapter: layers array must not be empty in composed mode'); + } + + // Track how many of each chart type we've seen for series indexing + const typeCounters = new Map(); + + return layers.map((layerConfig: RechartsLayerConfig, index: number) => { + const { yKey, chartType, name } = layerConfig; + const seriesIndex = typeCounters.get(chartType) ?? 0; + typeCounters.set(chartType, seriesIndex + 1); + + const maidrType = toTraceType(chartType); + const selector = selectorOverride ?? getRechartsSelector(chartType, seriesIndex); + const layerData = convertData(chartType, data, xKey, yKey); + + return { + id: String(index), + type: maidrType, + title: name, + selectors: selector, + orientation: orientation ?? (isBarType(chartType) ? Orientation.VERTICAL : undefined), + axes: { + x: xLabel, + y: yLabel, + }, + data: layerData, + } as MaidrLayer; + }); +} + +/** + * Converts Recharts data for a single series into the appropriate MAIDR format. + */ +function convertData( + chartType: RechartsChartType, + data: Record[], + xKey: string, + yKey: string, +): BarPoint[] | LinePoint[][] | ScatterPoint[] { + switch (chartType) { + case 'bar': + case 'pie': + case 'funnel': + return convertToBarPoints(data, xKey, yKey); + case 'line': + case 'area': + case 'radar': + return convertToLinePoints(data, xKey, yKey); + case 'scatter': + return convertToScatterPoints(data, xKey, yKey); + // Stacked/dodged/normalized/histogram handled by dedicated builders + case 'stacked_bar': + case 'dodged_bar': + case 'normalized_bar': + case 'histogram': + return convertToBarPoints(data, xKey, yKey); + } +} + +/** + * Converts data to BarPoint[] format. + * + * Used for: + * - Bar charts + * - Pie charts (sectors mapped as bar categories — screen readers will + * describe data using bar semantics such as "point N of M") + * - Funnel charts (segments mapped as bar categories — screen readers will + * describe data using bar semantics such as "point N of M") + */ +function convertToBarPoints( + data: Record[], + xKey: string, + yKey: string, +): BarPoint[] { + return data.map(item => ({ + x: item[xKey] as string | number, + y: toNumber(item[yKey]), + })); +} + +/** + * Converts data to LinePoint[][] format (single series as 2D array). + * Used for line, area, and radar charts. + * + * X-axis values are preserved as their original type (string or number). + * This avoids coercing category labels (e.g. 'Jan', 'Feb') to 0. + */ +function convertToLinePoints( + data: Record[], + xKey: string, + yKey: string, +): LinePoint[][] { + return [ + data.map(item => ({ + x: toLineX(item[xKey]), + y: toNumber(item[yKey]), + })), + ]; +} + +/** + * Converts data to ScatterPoint[] format. + */ +function convertToScatterPoints( + data: Record[], + xKey: string, + yKey: string, +): ScatterPoint[] { + return data.map(item => ({ + x: toNumber(item[xKey]), + y: toNumber(item[yKey]), + })); +} + +/** + * Returns true if the chart type produces bar-like visuals that benefit from orientation. + */ +function isBarType(chartType: RechartsChartType): boolean { + return chartType === 'bar' + || chartType === 'stacked_bar' + || chartType === 'dodged_bar' + || chartType === 'normalized_bar' + || chartType === 'histogram' + || chartType === 'funnel' + || chartType === 'pie'; +} + +/** + * Returns true if the chart type maps to a segmented bar MAIDR type. + */ +function isSegmentedBarType(chartType: RechartsChartType): boolean { + return chartType === 'stacked_bar' + || chartType === 'dodged_bar' + || chartType === 'normalized_bar'; +} + +/** + * Returns true if the chart type maps to a line-like MAIDR type. + */ +function isLineType(chartType: RechartsChartType): boolean { + return chartType === 'line' || chartType === 'area' || chartType === 'radar'; +} + +/** + * Maps Recharts chart types to MAIDR TraceType enum values. + * + * Note: `'pie'` and `'funnel'` map to `TraceType.BAR`. Screen readers will + * use bar terminology (e.g. "point 1 of 5") for these chart types because + * MAIDR does not yet have dedicated pie or funnel trace types. + */ +function toTraceType(chartType: RechartsChartType): TraceType { + switch (chartType) { + case 'bar': + return TraceType.BAR; + case 'stacked_bar': + return TraceType.STACKED; + case 'dodged_bar': + return TraceType.DODGED; + case 'normalized_bar': + return TraceType.NORMALIZED; + case 'histogram': + return TraceType.HISTOGRAM; + case 'line': + case 'area': + case 'radar': + return TraceType.LINE; + case 'scatter': + return TraceType.SCATTER; + case 'pie': + case 'funnel': + return TraceType.BAR; + } +} + +/** + * Converts an x-axis value for LinePoint. + * + * LinePoint.x accepts `number | string`, so we preserve the original type. + * Numbers pass through; strings are kept as-is (avoiding coercion of + * category labels like 'Jan' to 0). + */ +function toLineX(value: unknown): number | string { + if (typeof value === 'number') + return value; + if (typeof value === 'string') + return value; + return 0; +} + +/** + * Safely converts a value to a number. + * Returns 0 for null, undefined, or non-numeric values. + */ +function toNumber(value: unknown): number { + if (typeof value === 'number') + return value; + if (typeof value === 'string') { + const num = Number(value); + return Number.isNaN(num) ? 0 : num; + } + return 0; +} diff --git a/src/adapters/recharts/index.ts b/src/adapters/recharts/index.ts new file mode 100644 index 00000000..f15480b1 --- /dev/null +++ b/src/adapters/recharts/index.ts @@ -0,0 +1,21 @@ +/** + * Recharts adapter for MAIDR. + * + * Provides utilities to convert Recharts chart data and SVG structure + * into MAIDR's accessible format for audio sonification, text descriptions, + * braille output, and keyboard navigation. + * + * @packageDocumentation + */ + +export { convertRechartsToMaidr } from './converters'; +export { MaidrRecharts } from './MaidrRecharts'; +export { getRechartsSelector } from './selectors'; +export type { + HistogramBinConfig, + MaidrRechartsProps, + RechartsAdapterConfig, + RechartsChartType, + RechartsLayerConfig, +} from './types'; +export { useRechartsAdapter } from './useRechartsAdapter'; diff --git a/src/adapters/recharts/selectors.ts b/src/adapters/recharts/selectors.ts new file mode 100644 index 00000000..31b09694 --- /dev/null +++ b/src/adapters/recharts/selectors.ts @@ -0,0 +1,93 @@ +/** + * CSS selectors for Recharts SVG elements. + * + * Recharts renders SVG with specific class names that follow the pattern + * `.recharts-{component}-{element}`. These selectors target the individual + * data point elements that MAIDR uses for visual highlighting during + * keyboard navigation. + * + * SVG structure reference (Recharts v2.x): + * + * BarChart: + * g.recharts-bar > g.recharts-bar-rectangles > g.recharts-bar-rectangle > path.recharts-rectangle + * + * LineChart: + * g.recharts-line > g.recharts-line-dots > circle.recharts-line-dot + * + * AreaChart: + * g.recharts-area > g.recharts-area-dots > circle.recharts-area-dot + * + * ScatterChart: + * g.recharts-scatter > g.recharts-scatter-symbol > svg.recharts-symbols + * + * PieChart: + * g.recharts-pie > g.recharts-pie-sector > path.recharts-sector + * + * RadarChart: + * g.recharts-radar > g.recharts-radar-dots > circle.recharts-radar-dot + * + * FunnelChart: + * g.recharts-trapezoids > g.recharts-funnel-trapezoid > path.recharts-trapezoid + * + * ## Multi-series limitation + * + * Recharts renders axes, grids, and series as sibling `` elements inside + * a shared surface layer. Because they all share the same tag, CSS positional + * pseudo-classes (`:nth-child`, `:nth-of-type`) cannot reliably distinguish + * between series when non-series siblings are interspersed. + * + * For **single-series** charts the class selector is unambiguous and + * highlighting works out of the box. + * + * For **multi-series** charts the adapter returns `undefined` for selectors, + * which disables visual highlighting while preserving audio, text, and braille. + * To enable highlighting for multi-series charts, add a custom `className` to + * each Recharts component (e.g. ``) and pass the + * resulting selector via the `selectorOverride` config option. + */ + +import type { RechartsChartType } from './types'; + +/** + * Returns the CSS selector string for individual data point elements + * of the given Recharts chart type. + * + * Returns `undefined` when `seriesIndex` is provided, because CSS alone + * cannot reliably target a specific series in Recharts' SVG structure. + * See the module-level documentation for details. + * + * @param chartType - The Recharts chart type + * @param seriesIndex - When set, indicates a multi-series chart — returns undefined + * @returns CSS selector string, or undefined for multi-series targeting + */ +export function getRechartsSelector( + chartType: RechartsChartType, + seriesIndex?: number, +): string | undefined { + // Multi-series positional targeting is unreliable with CSS alone. + // Return undefined to gracefully disable highlighting. + if (seriesIndex != null) { + return undefined; + } + + switch (chartType) { + case 'bar': + case 'stacked_bar': + case 'dodged_bar': + case 'normalized_bar': + case 'histogram': + return '.recharts-bar-rectangle'; + case 'line': + return '.recharts-line-dot'; + case 'area': + return '.recharts-area-dot'; + case 'scatter': + return '.recharts-scatter-symbol'; + case 'pie': + return '.recharts-pie-sector'; + case 'radar': + return '.recharts-radar-dot'; + case 'funnel': + return '.recharts-funnel-trapezoid'; + } +} diff --git a/src/adapters/recharts/types.ts b/src/adapters/recharts/types.ts new file mode 100644 index 00000000..2cc105e2 --- /dev/null +++ b/src/adapters/recharts/types.ts @@ -0,0 +1,221 @@ +/** + * Types for the Recharts adapter. + * + * Defines the configuration interfaces for converting Recharts data + * and SVG structure into MAIDR's accessible format. + */ + +import type { Orientation } from '@type/grammar'; + +/** + * Recharts chart types supported by the adapter. + * + * Mapping to MAIDR trace types: + * - `'bar'` → `TraceType.BAR` — Simple bar chart + * - `'stacked_bar'` → `TraceType.STACKED` — Stacked bar chart (Recharts ``) + * - `'dodged_bar'` → `TraceType.DODGED` — Grouped/dodged bar chart (multiple `` without stackId) + * - `'normalized_bar'` → `TraceType.NORMALIZED` — Stacked normalized (100%) bar chart + * - `'histogram'` → `TraceType.HISTOGRAM` — Histogram rendered as bar chart with bin ranges + * - `'line'` → `TraceType.LINE` — Line chart + * - `'area'` → `TraceType.LINE` — Area chart (treated as line for sonification/navigation) + * - `'scatter'` → `TraceType.SCATTER` — Scatter/point plot + * - `'pie'` → `TraceType.BAR` — Pie chart (sectors mapped as bar categories) + * - `'radar'` → `TraceType.LINE` — Radar chart (dimensions mapped as line points) + * - `'funnel'` → `TraceType.BAR` — Funnel chart (segments mapped as bar categories) + */ +export type RechartsChartType + = | 'bar' + | 'stacked_bar' + | 'dodged_bar' + | 'normalized_bar' + | 'histogram' + | 'line' + | 'area' + | 'scatter' + | 'pie' + | 'radar' + | 'funnel'; + +/** + * A single data series/layer configuration for composed charts. + * Use this when a chart has multiple series of different types. + */ +export interface RechartsLayerConfig { + /** Key in the data array for this series' y-values. */ + yKey: string; + /** Chart type for this series. */ + chartType: RechartsChartType; + /** Display name for this series (used in legends/descriptions). */ + name?: string; +} + +/** + * Configuration for histogram bin ranges. + * Required when `chartType` is `'histogram'`. + */ +export interface HistogramBinConfig { + /** Key in data objects for the lower bin edge. */ + xMinKey: string; + /** Key in data objects for the upper bin edge. */ + xMaxKey: string; + /** Key in data objects for the minimum count (typically 0). Defaults to 0. */ + yMinKey?: string; + /** Key in data objects for the maximum count. Defaults to the yKey value. */ + yMaxKey?: string; +} + +/** + * Configuration for the Recharts-to-MAIDR adapter. + * + * Supports two configuration modes: + * 1. **Simple mode** — Set `chartType` and `yKeys` for a single chart type + * with one or more data series. + * 2. **Composed mode** — Set `layers` for mixed chart types (e.g., bar + line). + * + * @example Simple bar chart + * ```typescript + * const config: RechartsAdapterConfig = { + * id: 'sales-chart', + * title: 'Sales by Quarter', + * data: [{ quarter: 'Q1', revenue: 100 }, { quarter: 'Q2', revenue: 200 }], + * chartType: 'bar', + * xKey: 'quarter', + * yKeys: ['revenue'], + * xLabel: 'Quarter', + * yLabel: 'Revenue ($)', + * }; + * ``` + * + * @example Stacked bar chart + * ```typescript + * const config: RechartsAdapterConfig = { + * id: 'stacked-chart', + * title: 'Revenue by Product', + * data: [{ month: 'Jan', productA: 50, productB: 30 }], + * chartType: 'stacked_bar', + * xKey: 'month', + * yKeys: ['productA', 'productB'], + * fillKeys: ['Product A', 'Product B'], + * xLabel: 'Month', + * yLabel: 'Revenue', + * }; + * ``` + * + * @example Histogram + * ```typescript + * const config: RechartsAdapterConfig = { + * id: 'hist-chart', + * title: 'Score Distribution', + * data: [{ bin: '0-10', count: 5, xMin: 0, xMax: 10 }], + * chartType: 'histogram', + * xKey: 'bin', + * yKeys: ['count'], + * binConfig: { xMinKey: 'xMin', xMaxKey: 'xMax' }, + * xLabel: 'Score', + * yLabel: 'Frequency', + * }; + * ``` + * + * @example Composed chart (bar + line) + * ```typescript + * const config: RechartsAdapterConfig = { + * id: 'mixed-chart', + * title: 'Revenue and Trend', + * data: [{ month: 'Jan', revenue: 100, trend: 95 }], + * xKey: 'month', + * layers: [ + * { yKey: 'revenue', chartType: 'bar', name: 'Revenue' }, + * { yKey: 'trend', chartType: 'line', name: 'Trend' }, + * ], + * xLabel: 'Month', + * yLabel: 'Value', + * }; + * ``` + */ +export interface RechartsAdapterConfig { + /** Unique identifier for the chart (used for DOM IDs). */ + id: string; + + /** Chart title displayed in text descriptions. */ + title?: string; + + /** Chart subtitle. */ + subtitle?: string; + + /** Chart caption. */ + caption?: string; + + /** Recharts data array. Each item is one data point with named fields. */ + data: Record[]; + + /** + * Chart type for simple mode (single chart type with one or more series). + * Mutually exclusive with `layers`. + */ + chartType?: RechartsChartType; + + /** Key in data objects for x-axis values. */ + xKey: string; + + /** + * Keys in data objects for y-axis values (simple mode). + * Each key creates a separate data series. + * Mutually exclusive with `layers`. + */ + yKeys?: string[]; + + /** + * Layer configurations for composed charts (composed mode). + * Each layer defines a chart type and data key. + * Mutually exclusive with `chartType`/`yKeys`. + */ + layers?: RechartsLayerConfig[]; + + /** X-axis label. */ + xLabel?: string; + + /** Y-axis label. */ + yLabel?: string; + + /** Bar/box chart orientation. Defaults to vertical. */ + orientation?: Orientation; + + /** + * Display names for each series in stacked/dodged/normalized bar charts. + * Maps 1:1 with `yKeys` — the i-th fillKey names the i-th yKey. + * When omitted, the yKey strings are used as fill labels. + */ + fillKeys?: string[]; + + /** + * Histogram bin range configuration. + * Required when `chartType` is `'histogram'`. + */ + binConfig?: HistogramBinConfig; + + /** + * Custom CSS selector override for SVG highlighting. + * + * By default the adapter generates selectors from Recharts' built-in + * class names. For multi-series charts, CSS selectors cannot reliably + * distinguish between series, so highlighting is disabled. + * + * To enable highlighting for multi-series charts, add a custom + * `className` to each Recharts component and pass the selector here: + * + * @example + * ```tsx + * + * // then set selectorOverride: '.revenue-bar .recharts-bar-rectangle' + * ``` + */ + selectorOverride?: string; +} + +/** + * Props for the MaidrRecharts wrapper component. + */ +export interface MaidrRechartsProps extends RechartsAdapterConfig { + /** Recharts chart component(s) to make accessible. */ + children: React.ReactNode; +} diff --git a/src/adapters/recharts/useRechartsAdapter.ts b/src/adapters/recharts/useRechartsAdapter.ts new file mode 100644 index 00000000..a06e8b1e --- /dev/null +++ b/src/adapters/recharts/useRechartsAdapter.ts @@ -0,0 +1,93 @@ +/** + * React hook for converting Recharts configuration into MAIDR data. + * + * Provides a memoized conversion from Recharts adapter config to MaidrData, + * suitable for passing to the `` component's `data` prop. + * + * @example + * ```tsx + * import { Maidr } from 'maidr/react'; + * import { useRechartsAdapter } from 'maidr/recharts'; + * + * // Define data outside the component or useMemo to keep a stable reference. + * const chartData = [{ name: 'Q1', value: 100 }, { name: 'Q2', value: 200 }]; + * const yKeys = ['value']; + * + * function MyChart() { + * const maidrData = useRechartsAdapter({ + * id: 'sales-chart', + * title: 'Sales by Quarter', + * data: chartData, + * chartType: 'bar', + * xKey: 'name', + * yKeys, + * xLabel: 'Quarter', + * yLabel: 'Revenue', + * }); + * + * return ( + * + * + * + * + * + * ); + * } + * ``` + */ + +import type { Maidr } from '@type/grammar'; +import type { RechartsAdapterConfig } from './types'; +import { useMemo } from 'react'; +import { convertRechartsToMaidr } from './converters'; + +/** + * Converts Recharts configuration into MAIDR data format. + * + * The result is memoized: it only recomputes when individual config + * fields change. You do **not** need to stabilize the config object + * reference — the hook tracks each field independently. + * + * @param config - Recharts adapter configuration + * @returns MaidrData ready to pass to `` + */ +export function useRechartsAdapter(config: RechartsAdapterConfig): Maidr { + const { + id, + title, + subtitle, + caption, + data, + chartType, + xKey, + yKeys, + layers, + xLabel, + yLabel, + orientation, + fillKeys, + binConfig, + selectorOverride, + } = config; + + return useMemo( + () => convertRechartsToMaidr({ + id, + title, + subtitle, + caption, + data, + chartType, + xKey, + yKeys, + layers, + xLabel, + yLabel, + orientation, + fillKeys, + binConfig, + selectorOverride, + }), + [id, title, subtitle, caption, data, chartType, xKey, yKeys, layers, xLabel, yLabel, orientation, fillKeys, binConfig, selectorOverride], + ); +} diff --git a/src/recharts-entry.ts b/src/recharts-entry.ts new file mode 100644 index 00000000..20c181b9 --- /dev/null +++ b/src/recharts-entry.ts @@ -0,0 +1,96 @@ +/** + * Public Recharts adapter API for MAIDR. + * + * Provides a `` wrapper component and a `useRechartsAdapter` + * hook for making Recharts charts accessible through MAIDR's audio + * sonification, text descriptions, braille output, and keyboard navigation. + * + * @remarks + * Requires React 18 or 19 and Recharts as peer dependencies. + * The adapter converts Recharts data into MAIDR's internal schema and + * generates CSS selectors that target Recharts' SVG elements for highlighting. + * + * @example Using the wrapper component + * ```tsx + * import { MaidrRecharts } from 'maidr/recharts'; + * import { BarChart, Bar, XAxis, YAxis } from 'recharts'; + * + * const data = [ + * { name: 'Q1', revenue: 100 }, + * { name: 'Q2', revenue: 200 }, + * ]; + * + * function AccessibleChart() { + * return ( + * + * + * + * + * + * + * + * ); + * } + * ``` + * + * @example Using the hook with `` + * ```tsx + * import { Maidr } from 'maidr/react'; + * import { useRechartsAdapter } from 'maidr/recharts'; + * import { LineChart, Line, XAxis, YAxis } from 'recharts'; + * + * function AccessibleLineChart() { + * const data = [{ x: 1, y: 10 }, { x: 2, y: 20 }]; + * + * const maidrData = useRechartsAdapter({ + * id: 'line-chart', + * title: 'Trend', + * data, + * chartType: 'line', + * xKey: 'x', + * yKeys: ['y'], + * xLabel: 'Time', + * yLabel: 'Value', + * }); + * + * return ( + * + * + * + * + * + * + * + * ); + * } + * ``` + * + * @packageDocumentation + */ +export { + convertRechartsToMaidr, + getRechartsSelector, + MaidrRecharts, + useRechartsAdapter, +} from './adapters/recharts'; + +export type { + HistogramBinConfig, + MaidrRechartsProps, + RechartsAdapterConfig, + RechartsChartType, + RechartsLayerConfig, +} from './adapters/recharts'; + +// Re-export core types that consumers may need alongside the adapter +export type { Maidr as MaidrData, MaidrLayer, MaidrSubplot } from './type/grammar'; +export { Orientation, TraceType } from './type/grammar'; diff --git a/test/adapters/recharts/converters.test.ts b/test/adapters/recharts/converters.test.ts new file mode 100644 index 00000000..e74f6681 --- /dev/null +++ b/test/adapters/recharts/converters.test.ts @@ -0,0 +1,726 @@ +import type { RechartsAdapterConfig } from '@adapters/recharts/types'; +import type { BarPoint, HistogramPoint, LinePoint, ScatterPoint, SegmentedPoint } from '@type/grammar'; +import { convertRechartsToMaidr } from '@adapters/recharts/converters'; +import { TraceType } from '@type/grammar'; + +describe('convertRechartsToMaidr', () => { + describe('bar chart', () => { + it('converts simple bar chart data', () => { + const config: RechartsAdapterConfig = { + id: 'test-bar', + title: 'Test Bar', + data: [ + { name: 'A', value: 10 }, + { name: 'B', value: 20 }, + { name: 'C', value: 30 }, + ], + chartType: 'bar', + xKey: 'name', + yKeys: ['value'], + xLabel: 'Category', + yLabel: 'Value', + }; + + const result = convertRechartsToMaidr(config); + + expect(result.id).toBe('test-bar'); + expect(result.title).toBe('Test Bar'); + expect(result.subplots).toHaveLength(1); + expect(result.subplots[0]).toHaveLength(1); + + const layer = result.subplots[0][0].layers[0]; + expect(layer.type).toBe(TraceType.BAR); + expect(layer.axes?.x).toBe('Category'); + expect(layer.axes?.y).toBe('Value'); + expect(layer.selectors).toBe('.recharts-bar-rectangle'); + + const data = layer.data as BarPoint[]; + expect(data).toHaveLength(3); + expect(data[0]).toEqual({ x: 'A', y: 10 }); + expect(data[1]).toEqual({ x: 'B', y: 20 }); + expect(data[2]).toEqual({ x: 'C', y: 30 }); + }); + + it('creates separate layers for multi-series bar chart', () => { + const config: RechartsAdapterConfig = { + id: 'multi-bar', + data: [ + { name: 'A', s1: 10, s2: 20 }, + { name: 'B', s1: 15, s2: 25 }, + ], + chartType: 'bar', + xKey: 'name', + yKeys: ['s1', 's2'], + }; + + const result = convertRechartsToMaidr(config); + const layers = result.subplots[0][0].layers; + + expect(layers).toHaveLength(2); + expect(layers[0].title).toBe('s1'); + expect(layers[1].title).toBe('s2'); + + // Multi-series selectors are undefined (CSS unreliable) + expect(layers[0].selectors).toBeUndefined(); + expect(layers[1].selectors).toBeUndefined(); + + const data0 = layers[0].data as BarPoint[]; + expect(data0[0]).toEqual({ x: 'A', y: 10 }); + + const data1 = layers[1].data as BarPoint[]; + expect(data1[0]).toEqual({ x: 'A', y: 20 }); + }); + }); + + describe('stacked bar chart', () => { + it('converts stacked bar data to SegmentedPoint[][]', () => { + const config: RechartsAdapterConfig = { + id: 'stacked', + data: [ + { month: 'Jan', a: 10, b: 20 }, + { month: 'Feb', a: 15, b: 25 }, + ], + chartType: 'stacked_bar', + xKey: 'month', + yKeys: ['a', 'b'], + fillKeys: ['Product A', 'Product B'], + }; + + const result = convertRechartsToMaidr(config); + const layer = result.subplots[0][0].layers[0]; + + expect(layer.type).toBe(TraceType.STACKED); + + const data = layer.data as SegmentedPoint[][]; + expect(data).toHaveLength(2); + // First category (Jan): two segments + expect(data[0]).toHaveLength(2); + expect(data[0][0]).toEqual({ x: 'Jan', y: 10, fill: 'Product A' }); + expect(data[0][1]).toEqual({ x: 'Jan', y: 20, fill: 'Product B' }); + // Second category (Feb) + expect(data[1][0]).toEqual({ x: 'Feb', y: 15, fill: 'Product A' }); + expect(data[1][1]).toEqual({ x: 'Feb', y: 25, fill: 'Product B' }); + }); + + it('uses yKey names as fill labels when fillKeys omitted', () => { + const config: RechartsAdapterConfig = { + id: 'stacked', + data: [{ x: 'A', s1: 10, s2: 20 }], + chartType: 'stacked_bar', + xKey: 'x', + yKeys: ['s1', 's2'], + }; + + const result = convertRechartsToMaidr(config); + const data = result.subplots[0][0].layers[0].data as SegmentedPoint[][]; + + expect(data[0][0].fill).toBe('s1'); + expect(data[0][1].fill).toBe('s2'); + }); + + it('falls back to BAR type when stacked_bar has single yKey', () => { + const config: RechartsAdapterConfig = { + id: 'stacked-single', + data: [ + { x: 'A', y: 10 }, + { x: 'B', y: 20 }, + ], + chartType: 'stacked_bar', + xKey: 'x', + yKeys: ['y'], + }; + + const result = convertRechartsToMaidr(config); + const layer = result.subplots[0][0].layers[0]; + + expect(layer.type).toBe(TraceType.BAR); + + const data = layer.data as BarPoint[]; + expect(data[0]).toEqual({ x: 'A', y: 10 }); + }); + }); + + describe('dodged bar chart', () => { + it('produces DODGED trace type', () => { + const config: RechartsAdapterConfig = { + id: 'dodged', + data: [{ x: 'A', s1: 10, s2: 20 }], + chartType: 'dodged_bar', + xKey: 'x', + yKeys: ['s1', 's2'], + }; + + const result = convertRechartsToMaidr(config); + expect(result.subplots[0][0].layers[0].type).toBe(TraceType.DODGED); + }); + + it('falls back to BAR type when dodged_bar has single yKey', () => { + const config: RechartsAdapterConfig = { + id: 'dodged-single', + data: [{ x: 'A', y: 10 }], + chartType: 'dodged_bar', + xKey: 'x', + yKeys: ['y'], + }; + + const result = convertRechartsToMaidr(config); + expect(result.subplots[0][0].layers[0].type).toBe(TraceType.BAR); + }); + }); + + describe('normalized bar chart', () => { + it('produces NORMALIZED trace type', () => { + const config: RechartsAdapterConfig = { + id: 'normalized', + data: [{ x: 'A', s1: 10, s2: 20 }], + chartType: 'normalized_bar', + xKey: 'x', + yKeys: ['s1', 's2'], + }; + + const result = convertRechartsToMaidr(config); + expect(result.subplots[0][0].layers[0].type).toBe(TraceType.NORMALIZED); + }); + + it('falls back to BAR type when normalized_bar has single yKey', () => { + const config: RechartsAdapterConfig = { + id: 'normalized-single', + data: [{ x: 'A', y: 10 }], + chartType: 'normalized_bar', + xKey: 'x', + yKeys: ['y'], + }; + + const result = convertRechartsToMaidr(config); + expect(result.subplots[0][0].layers[0].type).toBe(TraceType.BAR); + }); + }); + + describe('histogram', () => { + it('converts histogram data with bin config', () => { + const config: RechartsAdapterConfig = { + id: 'hist', + data: [ + { bin: '0-10', count: 5, xMin: 0, xMax: 10 }, + { bin: '10-20', count: 8, xMin: 10, xMax: 20 }, + ], + chartType: 'histogram', + xKey: 'bin', + yKeys: ['count'], + binConfig: { xMinKey: 'xMin', xMaxKey: 'xMax' }, + }; + + const result = convertRechartsToMaidr(config); + const layer = result.subplots[0][0].layers[0]; + + expect(layer.type).toBe(TraceType.HISTOGRAM); + + const data = layer.data as HistogramPoint[]; + expect(data).toHaveLength(2); + expect(data[0]).toEqual({ x: '0-10', y: 5, xMin: 0, xMax: 10, yMin: 0, yMax: 5 }); + expect(data[1]).toEqual({ x: '10-20', y: 8, xMin: 10, xMax: 20, yMin: 0, yMax: 8 }); + }); + + it('defaults bin ranges to 0 when binConfig omitted', () => { + const config: RechartsAdapterConfig = { + id: 'hist', + data: [{ bin: 'A', count: 5 }], + chartType: 'histogram', + xKey: 'bin', + yKeys: ['count'], + }; + + const result = convertRechartsToMaidr(config); + const data = result.subplots[0][0].layers[0].data as HistogramPoint[]; + expect(data[0].xMin).toBe(0); + expect(data[0].xMax).toBe(0); + }); + }); + + describe('line chart', () => { + it('converts single series line data to LinePoint[][]', () => { + const config: RechartsAdapterConfig = { + id: 'line', + data: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + ], + chartType: 'line', + xKey: 'x', + yKeys: ['y'], + }; + + const result = convertRechartsToMaidr(config); + const layer = result.subplots[0][0].layers[0]; + + expect(layer.type).toBe(TraceType.LINE); + + const data = layer.data as LinePoint[][]; + expect(data).toHaveLength(1); + expect(data[0]).toHaveLength(2); + expect(data[0][0]).toEqual({ x: 1, y: 10 }); + }); + + it('preserves string x-axis values for line charts', () => { + const config: RechartsAdapterConfig = { + id: 'line-string-x', + data: [ + { month: 'Jan', value: 10 }, + { month: 'Feb', value: 20 }, + ], + chartType: 'line', + xKey: 'month', + yKeys: ['value'], + }; + + const result = convertRechartsToMaidr(config); + const data = result.subplots[0][0].layers[0].data as LinePoint[][]; + + expect(data[0][0]).toEqual({ x: 'Jan', y: 10 }); + expect(data[0][1]).toEqual({ x: 'Feb', y: 20 }); + }); + + it('converts multi-series line data with fill labels', () => { + const config: RechartsAdapterConfig = { + id: 'multi-line', + data: [ + { x: 1, s1: 10, s2: 20 }, + { x: 2, s1: 15, s2: 25 }, + ], + chartType: 'line', + xKey: 'x', + yKeys: ['s1', 's2'], + }; + + const result = convertRechartsToMaidr(config); + const layer = result.subplots[0][0].layers[0]; + + expect(layer.type).toBe(TraceType.LINE); + + const data = layer.data as LinePoint[][]; + expect(data).toHaveLength(2); + expect(data[0][0]).toEqual({ x: 1, y: 10, fill: 's1' }); + expect(data[1][0]).toEqual({ x: 1, y: 20, fill: 's2' }); + }); + + it('preserves string x-axis values in multi-series line charts', () => { + const config: RechartsAdapterConfig = { + id: 'multi-line-string', + data: [ + { month: 'Jan', s1: 10, s2: 20 }, + { month: 'Feb', s1: 15, s2: 25 }, + ], + chartType: 'line', + xKey: 'month', + yKeys: ['s1', 's2'], + }; + + const result = convertRechartsToMaidr(config); + const data = result.subplots[0][0].layers[0].data as LinePoint[][]; + + expect(data[0][0].x).toBe('Jan'); + expect(data[1][0].x).toBe('Jan'); + }); + + it('returns undefined selectors for multi-series line', () => { + const config: RechartsAdapterConfig = { + id: 'multi-line', + data: [ + { x: 1, s1: 10, s2: 20 }, + ], + chartType: 'line', + xKey: 'x', + yKeys: ['s1', 's2'], + }; + + const result = convertRechartsToMaidr(config); + const selectors = result.subplots[0][0].layers[0].selectors; + + // Multi-series selectors are omitted (undefined) because CSS + // cannot reliably distinguish series in Recharts SVG + expect(selectors).toBeUndefined(); + }); + }); + + describe('area chart', () => { + it('maps area to LINE trace type', () => { + const config: RechartsAdapterConfig = { + id: 'area', + data: [{ x: 1, y: 10 }], + chartType: 'area', + xKey: 'x', + yKeys: ['y'], + }; + + const result = convertRechartsToMaidr(config); + expect(result.subplots[0][0].layers[0].type).toBe(TraceType.LINE); + }); + + it('preserves string x-axis values for area charts', () => { + const config: RechartsAdapterConfig = { + id: 'area-string-x', + data: [{ month: 'Jan', value: 10 }], + chartType: 'area', + xKey: 'month', + yKeys: ['value'], + }; + + const result = convertRechartsToMaidr(config); + const data = result.subplots[0][0].layers[0].data as LinePoint[][]; + expect(data[0][0].x).toBe('Jan'); + }); + }); + + describe('scatter chart', () => { + it('converts scatter data to ScatterPoint[]', () => { + const config: RechartsAdapterConfig = { + id: 'scatter', + data: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + ], + chartType: 'scatter', + xKey: 'x', + yKeys: ['y'], + }; + + const result = convertRechartsToMaidr(config); + const layer = result.subplots[0][0].layers[0]; + + expect(layer.type).toBe(TraceType.SCATTER); + + const data = layer.data as ScatterPoint[]; + expect(data).toHaveLength(2); + expect(data[0]).toEqual({ x: 1, y: 10 }); + }); + }); + + describe('pie chart', () => { + it('maps pie to BAR trace type', () => { + const config: RechartsAdapterConfig = { + id: 'pie', + data: [ + { name: 'A', value: 30 }, + { name: 'B', value: 70 }, + ], + chartType: 'pie', + xKey: 'name', + yKeys: ['value'], + }; + + const result = convertRechartsToMaidr(config); + const layer = result.subplots[0][0].layers[0]; + + expect(layer.type).toBe(TraceType.BAR); + expect(layer.selectors).toBe('.recharts-pie-sector'); + + const data = layer.data as BarPoint[]; + expect(data[0]).toEqual({ x: 'A', y: 30 }); + }); + }); + + describe('radar chart', () => { + it('maps radar to LINE trace type', () => { + const config: RechartsAdapterConfig = { + id: 'radar', + data: [ + { subject: 'Math', score: 80 }, + { subject: 'English', score: 90 }, + ], + chartType: 'radar', + xKey: 'subject', + yKeys: ['score'], + }; + + const result = convertRechartsToMaidr(config); + const layer = result.subplots[0][0].layers[0]; + + expect(layer.type).toBe(TraceType.LINE); + expect(layer.selectors).toBe('.recharts-radar-dot'); + }); + + it('preserves string x-axis values for radar charts', () => { + const config: RechartsAdapterConfig = { + id: 'radar-string-x', + data: [{ subject: 'Math', score: 80 }], + chartType: 'radar', + xKey: 'subject', + yKeys: ['score'], + }; + + const result = convertRechartsToMaidr(config); + const data = result.subplots[0][0].layers[0].data as LinePoint[][]; + expect(data[0][0].x).toBe('Math'); + }); + }); + + describe('funnel chart', () => { + it('maps funnel to BAR trace type', () => { + const config: RechartsAdapterConfig = { + id: 'funnel', + data: [ + { name: 'Visits', value: 1000 }, + { name: 'Signups', value: 500 }, + ], + chartType: 'funnel', + xKey: 'name', + yKeys: ['value'], + }; + + const result = convertRechartsToMaidr(config); + const layer = result.subplots[0][0].layers[0]; + + expect(layer.type).toBe(TraceType.BAR); + expect(layer.selectors).toBe('.recharts-funnel-trapezoid'); + }); + }); + + describe('composed chart (layers mode)', () => { + it('creates mixed layers from layer configs', () => { + const config: RechartsAdapterConfig = { + id: 'composed', + data: [ + { month: 'Jan', revenue: 100, trend: 95 }, + { month: 'Feb', revenue: 150, trend: 140 }, + ], + xKey: 'month', + layers: [ + { yKey: 'revenue', chartType: 'bar', name: 'Revenue' }, + { yKey: 'trend', chartType: 'line', name: 'Trend' }, + ], + xLabel: 'Month', + yLabel: 'Value', + }; + + const result = convertRechartsToMaidr(config); + const layers = result.subplots[0][0].layers; + + expect(layers).toHaveLength(2); + expect(layers[0].type).toBe(TraceType.BAR); + expect(layers[0].title).toBe('Revenue'); + expect(layers[1].type).toBe(TraceType.LINE); + expect(layers[1].title).toBe('Trend'); + }); + + it('returns undefined selectors for multiple same-type layers', () => { + const config: RechartsAdapterConfig = { + id: 'composed', + data: [{ x: 'A', y1: 10, y2: 20 }], + xKey: 'x', + layers: [ + { yKey: 'y1', chartType: 'bar', name: 'Bar 1' }, + { yKey: 'y2', chartType: 'bar', name: 'Bar 2' }, + ], + }; + + const result = convertRechartsToMaidr(config); + const layers = result.subplots[0][0].layers; + + // First bar has seriesIndex 0 → getRechartsSelector returns undefined + // Second bar has seriesIndex 1 → also undefined + // (CSS cannot reliably distinguish series in Recharts) + expect(layers[0].selectors).toBeUndefined(); + expect(layers[1].selectors).toBeUndefined(); + }); + + it('returns selector for single-occurrence chart types in composed mode', () => { + const config: RechartsAdapterConfig = { + id: 'composed', + data: [{ x: 'A', y1: 10, y2: 20 }], + xKey: 'x', + layers: [ + { yKey: 'y1', chartType: 'bar', name: 'Bar' }, + { yKey: 'y2', chartType: 'line', name: 'Line' }, + ], + }; + + const result = convertRechartsToMaidr(config); + const layers = result.subplots[0][0].layers; + + // Each type appears once, seriesIndex = 0, so getRechartsSelector + // still returns undefined because seriesIndex is provided + expect(layers[0].selectors).toBeUndefined(); + expect(layers[1].selectors).toBeUndefined(); + }); + }); + + describe('selectorOverride', () => { + it('uses selectorOverride for simple bar chart', () => { + const config: RechartsAdapterConfig = { + id: 'override', + data: [{ x: 'A', y: 10 }], + chartType: 'bar', + xKey: 'x', + yKeys: ['y'], + selectorOverride: '.custom-bar', + }; + + const result = convertRechartsToMaidr(config); + expect(result.subplots[0][0].layers[0].selectors).toBe('.custom-bar'); + }); + + it('uses selectorOverride for multi-series bar chart', () => { + const config: RechartsAdapterConfig = { + id: 'override-multi', + data: [{ x: 'A', s1: 10, s2: 20 }], + chartType: 'bar', + xKey: 'x', + yKeys: ['s1', 's2'], + selectorOverride: '.custom-bar', + }; + + const result = convertRechartsToMaidr(config); + const layers = result.subplots[0][0].layers; + + expect(layers[0].selectors).toBe('.custom-bar'); + expect(layers[1].selectors).toBe('.custom-bar'); + }); + + it('uses selectorOverride for segmented bar chart', () => { + const config: RechartsAdapterConfig = { + id: 'override-stacked', + data: [{ x: 'A', s1: 10, s2: 20 }], + chartType: 'stacked_bar', + xKey: 'x', + yKeys: ['s1', 's2'], + selectorOverride: '.custom-stacked', + }; + + const result = convertRechartsToMaidr(config); + expect(result.subplots[0][0].layers[0].selectors).toBe('.custom-stacked'); + }); + + it('uses selectorOverride for histogram', () => { + const config: RechartsAdapterConfig = { + id: 'override-hist', + data: [{ bin: 'A', count: 5 }], + chartType: 'histogram', + xKey: 'bin', + yKeys: ['count'], + selectorOverride: '.custom-hist', + }; + + const result = convertRechartsToMaidr(config); + expect(result.subplots[0][0].layers[0].selectors).toBe('.custom-hist'); + }); + + it('uses selectorOverride for multi-series line chart', () => { + const config: RechartsAdapterConfig = { + id: 'override-line', + data: [{ x: 1, s1: 10, s2: 20 }], + chartType: 'line', + xKey: 'x', + yKeys: ['s1', 's2'], + selectorOverride: '.custom-line', + }; + + const result = convertRechartsToMaidr(config); + const selectors = result.subplots[0][0].layers[0].selectors; + expect(selectors).toEqual(['.custom-line', '.custom-line']); + }); + + it('uses selectorOverride in composed mode', () => { + const config: RechartsAdapterConfig = { + id: 'override-composed', + data: [{ x: 'A', y1: 10, y2: 20 }], + xKey: 'x', + layers: [ + { yKey: 'y1', chartType: 'bar', name: 'Bar' }, + { yKey: 'y2', chartType: 'line', name: 'Line' }, + ], + selectorOverride: '.custom-selector', + }; + + const result = convertRechartsToMaidr(config); + const layers = result.subplots[0][0].layers; + + expect(layers[0].selectors).toBe('.custom-selector'); + expect(layers[1].selectors).toBe('.custom-selector'); + }); + }); + + describe('metadata', () => { + it('passes through title, subtitle, caption', () => { + const config: RechartsAdapterConfig = { + id: 'meta', + title: 'Title', + subtitle: 'Subtitle', + caption: 'Caption', + data: [{ x: 'A', y: 1 }], + chartType: 'bar', + xKey: 'x', + yKeys: ['y'], + }; + + const result = convertRechartsToMaidr(config); + expect(result.title).toBe('Title'); + expect(result.subtitle).toBe('Subtitle'); + expect(result.caption).toBe('Caption'); + }); + }); + + describe('error handling', () => { + it('throws when chartType and layers are both missing', () => { + const config: RechartsAdapterConfig = { + id: 'bad', + data: [{ x: 'A', y: 1 }], + xKey: 'x', + }; + + expect(() => convertRechartsToMaidr(config)).toThrow('RechartsAdapter'); + }); + + it('throws when yKeys is empty in simple mode', () => { + const config: RechartsAdapterConfig = { + id: 'bad', + data: [{ x: 'A', y: 1 }], + chartType: 'bar', + xKey: 'x', + yKeys: [], + }; + + expect(() => convertRechartsToMaidr(config)).toThrow('RechartsAdapter'); + }); + + it('throws when layers is empty in composed mode', () => { + const config: RechartsAdapterConfig = { + id: 'bad', + data: [{ x: 'A', y: 1 }], + xKey: 'x', + layers: [], + }; + + expect(() => convertRechartsToMaidr(config)).toThrow('RechartsAdapter'); + }); + }); + + describe('data coercion', () => { + it('coerces string numbers to numbers for scatter', () => { + const config: RechartsAdapterConfig = { + id: 'coerce', + data: [{ x: '1', y: '10.5' }], + chartType: 'scatter', + xKey: 'x', + yKeys: ['y'], + }; + + const result = convertRechartsToMaidr(config); + const data = result.subplots[0][0].layers[0].data as ScatterPoint[]; + expect(data[0]).toEqual({ x: 1, y: 10.5 }); + }); + + it('returns 0 for missing or invalid values', () => { + const config: RechartsAdapterConfig = { + id: 'coerce', + data: [{ x: 'A', y: null }], + chartType: 'scatter', + xKey: 'x', + yKeys: ['y'], + }; + + const result = convertRechartsToMaidr(config); + const data = result.subplots[0][0].layers[0].data as ScatterPoint[]; + expect(data[0]).toEqual({ x: 0, y: 0 }); + }); + }); +}); diff --git a/test/adapters/recharts/selectors.test.ts b/test/adapters/recharts/selectors.test.ts new file mode 100644 index 00000000..751ef5dc --- /dev/null +++ b/test/adapters/recharts/selectors.test.ts @@ -0,0 +1,76 @@ +import { getRechartsSelector } from '@adapters/recharts/selectors'; + +describe('getRechartsSelector', () => { + describe('single series (no seriesIndex)', () => { + it('returns bar rectangle selector for bar type', () => { + expect(getRechartsSelector('bar')).toBe('.recharts-bar-rectangle'); + }); + + it('returns bar rectangle selector for stacked_bar type', () => { + expect(getRechartsSelector('stacked_bar')).toBe('.recharts-bar-rectangle'); + }); + + it('returns bar rectangle selector for dodged_bar type', () => { + expect(getRechartsSelector('dodged_bar')).toBe('.recharts-bar-rectangle'); + }); + + it('returns bar rectangle selector for normalized_bar type', () => { + expect(getRechartsSelector('normalized_bar')).toBe('.recharts-bar-rectangle'); + }); + + it('returns bar rectangle selector for histogram type', () => { + expect(getRechartsSelector('histogram')).toBe('.recharts-bar-rectangle'); + }); + + it('returns line dot selector for line type', () => { + expect(getRechartsSelector('line')).toBe('.recharts-line-dot'); + }); + + it('returns area dot selector for area type', () => { + expect(getRechartsSelector('area')).toBe('.recharts-area-dot'); + }); + + it('returns scatter symbol selector for scatter type', () => { + expect(getRechartsSelector('scatter')).toBe('.recharts-scatter-symbol'); + }); + + it('returns pie sector selector for pie type', () => { + expect(getRechartsSelector('pie')).toBe('.recharts-pie-sector'); + }); + + it('returns radar dot selector for radar type', () => { + expect(getRechartsSelector('radar')).toBe('.recharts-radar-dot'); + }); + + it('returns funnel trapezoid selector for funnel type', () => { + expect(getRechartsSelector('funnel')).toBe('.recharts-funnel-trapezoid'); + }); + }); + + describe('multi-series (with seriesIndex)', () => { + it('returns undefined for bar with seriesIndex', () => { + expect(getRechartsSelector('bar', 0)).toBeUndefined(); + }); + + it('returns undefined for line with seriesIndex', () => { + expect(getRechartsSelector('line', 1)).toBeUndefined(); + }); + + it('returns undefined for scatter with seriesIndex', () => { + expect(getRechartsSelector('scatter', 0)).toBeUndefined(); + }); + + it('returns undefined for radar with seriesIndex', () => { + expect(getRechartsSelector('radar', 0)).toBeUndefined(); + }); + + it('returns undefined for funnel with seriesIndex', () => { + expect(getRechartsSelector('funnel', 0)).toBeUndefined(); + }); + + it('returns undefined regardless of seriesIndex value', () => { + expect(getRechartsSelector('bar', 2)).toBeUndefined(); + expect(getRechartsSelector('line', 5)).toBeUndefined(); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index f3dbee87..0fcbad36 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "module": "ESNext", "moduleResolution": "bundler", "paths": { + "@adapters/*": ["adapters/*"], "@command/*": ["command/*"], "@model/*": ["model/*"], "@state/*": ["state/*"], diff --git a/vite.recharts.config.ts b/vite.recharts.config.ts new file mode 100644 index 00000000..93766565 --- /dev/null +++ b/vite.recharts.config.ts @@ -0,0 +1,54 @@ +import path from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +export default defineConfig({ + plugins: [ + react(), + dts({ + tsconfigPath: './tsconfig.build.json', + rollupTypes: true, + insertTypesEntry: false, + }), + ], + build: { + lib: { + entry: path.resolve(__dirname, 'src/recharts-entry.ts'), + formats: ['es'], + fileName: () => 'recharts.mjs', + }, + sourcemap: true, + outDir: 'dist', + emptyOutDir: false, + rollupOptions: { + external: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'recharts', + ], + onwarn(warning, warn) { + if (warning.code === 'MODULE_LEVEL_DIRECTIVE') { + return; + } + warn(warning); + }, + }, + }, + define: { + 'process.env': {}, + }, + resolve: { + alias: { + '@adapters': path.resolve(__dirname, 'src/adapters'), + '@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'), + }, + }, +});