diff --git a/package-lock.json b/package-lock.json index 02c70fcd..c30b4e2f 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", @@ -60,7 +60,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", + "victory": "^37.0.0" }, "peerDependenciesMeta": { "react": { @@ -68,6 +69,9 @@ }, "react-dom": { "optional": true + }, + "victory": { + "optional": true } } }, diff --git a/package.json b/package.json index ceb2b908..7cc2f4c1 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,11 @@ "types": "./dist/react.d.mts", "import": "./dist/react.mjs", "default": "./dist/react.mjs" + }, + "./victory": { + "types": "./dist/victory.d.mts", + "import": "./dist/victory.mjs", + "default": "./dist/victory.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.victory.config.ts", "build:script": "vite build", "build:react": "vite build --config vite.react.config.ts", + "build:victory": "vite build --config vite.victory.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", + "victory": "^37.0.0" }, "peerDependenciesMeta": { "react": { @@ -50,6 +57,9 @@ }, "react-dom": { "optional": true + }, + "victory": { + "optional": true } }, "dependencies": { diff --git a/src/victory/MaidrVictory.tsx b/src/victory/MaidrVictory.tsx new file mode 100644 index 00000000..65309296 --- /dev/null +++ b/src/victory/MaidrVictory.tsx @@ -0,0 +1,159 @@ +import type { Maidr as MaidrData, MaidrLayer } from '@type/grammar'; +import type { JSX } from 'react'; +import type { MaidrVictoryProps, VictoryLayerInfo } from './types'; +import { useLayoutEffect, useRef, useState } from 'react'; +import { Maidr } from '../maidr-component'; +import { extractVictoryLayers, toMaidrLayer } from './adapter'; +import { clearTaggedElements, tagLayerElements } from './selectors'; + +/** + * Collects all legend labels across layers that define them. + */ +function collectLegend(layers: VictoryLayerInfo[]): string[] | undefined { + const allLabels: string[] = []; + for (const layer of layers) { + if (layer.legend) + allLabels.push(...layer.legend); + } + return allLabels.length > 0 ? allLabels : undefined; +} + +/** + * Produces a stable, serialisable fingerprint from extracted Victory layers. + * + * React `children` creates a new object reference on every render, which + * makes it an unstable dependency for hooks. Instead we compare the + * JSON-serialisable layer data: if the actual chart data hasn't changed + * the effect can skip DOM re-tagging. + */ +function layerFingerprint(layers: VictoryLayerInfo[]): string { + return JSON.stringify(layers.map(l => ({ + t: l.victoryType, + d: l.data, + n: l.dataCount, + }))); +} + +/** + * React component that wraps Victory chart components and provides + * accessible, non-visual access through MAIDR's audio sonification, + * text descriptions, braille output, and keyboard navigation. + * + * Supports all Victory data components that have MAIDR equivalents: + * - `VictoryBar` → bar chart + * - `VictoryLine` → line chart + * - `VictoryArea` → line chart (filled) + * - `VictoryScatter` → scatter plot + * - `VictoryPie` → bar chart (categorical data) + * - `VictoryBoxPlot` → box plot + * - `VictoryCandlestick` → candlestick chart + * - `VictoryHistogram` → histogram + * - `VictoryGroup` → dodged/grouped bar chart + * - `VictoryStack` → stacked bar chart + * + * @example + * ```tsx + * import { MaidrVictory } from 'maidr/victory'; + * import { VictoryChart, VictoryBar } from 'victory'; + * + * function AccessibleBarChart() { + * return ( + * + * + * + * + * + * ); + * } + * ``` + */ +export function MaidrVictory({ + id, + title, + subtitle, + caption, + children, +}: MaidrVictoryProps): JSX.Element { + const containerRef = useRef(null); + const prevFingerprintRef = useRef(''); + const [maidrData, setMaidrData] = useState(() => ({ + id, + title, + subtitle, + caption, + subplots: [[{ layers: [] }]], + })); + + // useLayoutEffect runs synchronously after DOM mutations, before paint. + // This guarantees Victory's SVG elements exist when we inspect the DOM + // for selector tagging. + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) + return; + + // Extract data from Victory component props via React children + // introspection (pure computation, does not require DOM). + const victoryLayers = extractVictoryLayers(children); + + // Skip DOM re-tagging when the underlying data hasn't changed. + // This avoids redundant work when the parent re-renders with the + // same chart data (children reference changes but content is equal). + const fp = layerFingerprint(victoryLayers); + if (fp === prevFingerprintRef.current) + return; + prevFingerprintRef.current = fp; + + // Clear stale data-maidr-victory-* attributes from a previous tagging + // pass to avoid ghost selectors. + clearTaggedElements(container); + + if (victoryLayers.length === 0) { + // Preserve metadata (title, subtitle, caption) even when no + // supported Victory data components are found. + setMaidrData({ + id, + title, + subtitle, + caption, + subplots: [[{ layers: [] }]], + }); + return; + } + + // Tag rendered SVG elements with data attributes for reliable + // CSS-selector-based highlighting. + const claimed = new Set(); + const maidrLayers: MaidrLayer[] = victoryLayers.map((layer, index) => { + const selector = tagLayerElements(container, layer, index, claimed); + return toMaidrLayer(layer, selector); + }); + + // Build the MAIDR data schema. + setMaidrData({ + id, + title, + subtitle, + caption, + subplots: [[{ + layers: maidrLayers, + legend: collectLegend(victoryLayers), + }]], + }); + }); + + return ( + +
+ {children} +
+
+ ); +} diff --git a/src/victory/adapter.ts b/src/victory/adapter.ts new file mode 100644 index 00000000..347baa88 --- /dev/null +++ b/src/victory/adapter.ts @@ -0,0 +1,588 @@ +import type { + BarPoint, + BoxPoint, + CandlestickPoint, + CandlestickTrend, + HistogramPoint, + LinePoint, + MaidrLayer, + ScatterPoint, + SegmentedPoint, +} from '@type/grammar'; +import type { ReactElement, ReactNode } from 'react'; +import type { VictoryComponentType, VictoryLayerData, VictoryLayerInfo } from './types'; +import { TraceType } from '@type/grammar'; +import { Children, isValidElement } from 'react'; + +// --------------------------------------------------------------------------- +// Component name detection +// --------------------------------------------------------------------------- + +/** + * Resolves the Victory component display name from a React element type. + * + * Victory components set `displayName` on their exported functions/classes. + * This also handles HOC-wrapped components by checking common wrapper + * conventions such as `WrappedComponent`, `render`, and `type`. + */ +function getVictoryDisplayName(type: unknown): string | null { + if (!type) + return null; + + if (typeof type === 'function' || typeof type === 'object') { + const obj = type as Record; + + // Direct displayName or function name + const name = (obj.displayName as string | undefined) + ?? (obj.name as string | undefined) + ?? ''; + if (name.startsWith('Victory')) + return name; + + // HOC-wrapped components (e.g. React.memo, React.forwardRef) + if (obj.WrappedComponent) + return getVictoryDisplayName(obj.WrappedComponent); + if (obj.render) + return getVictoryDisplayName(obj.render); + if (obj.type) + return getVictoryDisplayName(obj.type); + } + + return null; +} + +/** + * Checks whether a display name corresponds to a supported Victory data + * component. Container components (VictoryGroup, VictoryStack, VictoryChart) + * are handled separately. + */ +function isDataComponent(name: string): name is VictoryComponentType { + return ( + name === 'VictoryBar' + || name === 'VictoryLine' + || name === 'VictoryScatter' + || name === 'VictoryArea' + || name === 'VictoryPie' + || name === 'VictoryBoxPlot' + || name === 'VictoryCandlestick' + || name === 'VictoryHistogram' + ); +} + +// --------------------------------------------------------------------------- +// Data accessors +// --------------------------------------------------------------------------- + +/** + * Resolves the data accessor for a Victory component prop. + * + * Victory allows `x` and `y` to be a string key, a function, or omitted + * (defaults to "x" / "y"). + */ +function resolveAccessor(accessor: unknown, fallback: string): (d: Record) => unknown { + if (typeof accessor === 'function') + return accessor as (d: Record) => unknown; + if (typeof accessor === 'string') + return (d: Record) => d[accessor]; + return (d: Record) => d[fallback]; +} + +// --------------------------------------------------------------------------- +// Axis label extraction +// --------------------------------------------------------------------------- + +/** + * Extracts axis labels from VictoryAxis children inside a VictoryChart. + */ +function extractAxisLabels(children: ReactNode): { x?: string; y?: string } { + const result: { x?: string; y?: string } = {}; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) + return; + const name = getVictoryDisplayName(child.type); + if (name !== 'VictoryAxis') + return; + + const props = child.props as Record; + const label = props.label as string | undefined; + if (!label) + return; + + if (props.dependentAxis) { + result.y = label; + } else { + result.x = label; + } + }); + + return result; +} + +// --------------------------------------------------------------------------- +// Per-component data extraction +// --------------------------------------------------------------------------- + +/** + * Validates that `rawData` is a non-empty array of objects. + */ +function validateRawData(rawData: unknown): rawData is Record[] { + return Array.isArray(rawData) && rawData.length > 0 + && typeof rawData[0] === 'object' && rawData[0] !== null; +} + +/** + * Extracts data from a VictoryBar or VictoryPie element. + */ +function extractBarData( + props: Record, +): { data: VictoryLayerData; count: number } | null { + const rawData = props.data; + if (!validateRawData(rawData)) + return null; + + const getX = resolveAccessor(props.x, 'x'); + const getY = resolveAccessor(props.y, 'y'); + const points: BarPoint[] = rawData.map(d => ({ + x: getX(d) as string | number, + y: getY(d) as number | string, + })); + + return { data: { kind: 'bar', points }, count: rawData.length }; +} + +/** + * Extracts data from a VictoryLine or VictoryArea element. + */ +function extractLineData( + props: Record, +): { data: VictoryLayerData; count: number } | null { + const rawData = props.data; + if (!validateRawData(rawData)) + return null; + + const getX = resolveAccessor(props.x, 'x'); + const getY = resolveAccessor(props.y, 'y'); + const points: LinePoint[] = rawData.map(d => ({ + x: getX(d) as number | string, + y: Number(getY(d)), + })); + + return { data: { kind: 'line', points: [points] }, count: rawData.length }; +} + +/** + * Extracts data from a VictoryScatter element. + */ +function extractScatterData( + props: Record, +): { data: VictoryLayerData; count: number } | null { + const rawData = props.data; + if (!validateRawData(rawData)) + return null; + + const getX = resolveAccessor(props.x, 'x'); + const getY = resolveAccessor(props.y, 'y'); + const points: ScatterPoint[] = rawData.map(d => ({ + x: Number(getX(d)), + y: Number(getY(d)), + })); + + return { data: { kind: 'scatter', points }, count: rawData.length }; +} + +/** + * Extracts data from a VictoryBoxPlot element. + * + * Victory box plots accept pre-computed statistics: + * `{ x, min, q1, median, q3, max }` + * or an array of y values from which Victory derives statistics. + * We only support the pre-computed form for reliable data extraction. + */ +function extractBoxData( + props: Record, +): { data: VictoryLayerData; count: number } | null { + const rawData = props.data; + if (!validateRawData(rawData)) + return null; + + const points: BoxPoint[] = rawData.map((d) => { + const x = d.x as string | number; + return { + fill: String(x), + lowerOutliers: (d.lowerOutliers ?? []) as number[], + min: Number(d.min ?? 0), + q1: Number(d.q1 ?? 0), + q2: Number(d.median ?? d.q2 ?? 0), + q3: Number(d.q3 ?? 0), + max: Number(d.max ?? 0), + upperOutliers: (d.upperOutliers ?? []) as number[], + }; + }); + + return { data: { kind: 'box', points }, count: rawData.length }; +} + +/** + * Extracts data from a VictoryCandlestick element. + * + * Victory candlestick data: `{ x, open, close, high, low }` + */ +function extractCandlestickData( + props: Record, +): { data: VictoryLayerData; count: number } | null { + const rawData = props.data; + if (!validateRawData(rawData)) + return null; + + const points: CandlestickPoint[] = rawData.map((d) => { + const open = Number(d.open); + const close = Number(d.close); + const high = Number(d.high); + const low = Number(d.low); + + let trend: CandlestickTrend = 'Neutral'; + if (close > open) + trend = 'Bull'; + else if (close < open) + trend = 'Bear'; + + return { + value: String(d.x), + open, + high, + low, + close, + volume: Number(d.volume ?? 0), + trend, + volatility: high - low, + }; + }); + + return { data: { kind: 'candlestick', points }, count: rawData.length }; +} + +/** + * Extracts data from a VictoryHistogram element. + * + * VictoryHistogram accepts raw values and a `bins` prop. Since Victory + * computes bins internally during render, we derive equal-width bins + * ourselves from the raw data to produce MAIDR's `HistogramPoint[]`. + */ +function extractHistogramData( + props: Record, +): { data: VictoryLayerData; count: number } | null { + const rawData = props.data; + if (!validateRawData(rawData)) + return null; + + const getX = resolveAccessor(props.x, 'x'); + const values = rawData.map(d => Number(getX(d))); + + const binCount = typeof props.bins === 'number' + ? (props.bins as number) + : Math.ceil(Math.sqrt(values.length)); + + // Use reduce instead of Math.min/max(...values) to avoid stack overflow + // on large datasets (spread arguments hit the engine's call stack limit + // at ~100k elements). + const min = values.reduce((a, b) => (a < b ? a : b), values[0]); + const max = values.reduce((a, b) => (a > b ? a : b), values[0]); + const binWidth = (max - min) / binCount || 1; + + // Build histogram bins + const bins = Array.from( + { length: binCount }, + (_, i) => ({ + count: 0, + xMin: min + i * binWidth, + xMax: min + (i + 1) * binWidth, + }), + ); + + for (const v of values) { + let idx = Math.floor((v - min) / binWidth); + if (idx >= binCount) + idx = binCount - 1; + bins[idx].count++; + } + + const points: HistogramPoint[] = bins.map(b => ({ + x: `${b.xMin.toFixed(1)}-${b.xMax.toFixed(1)}`, + y: b.count, + xMin: b.xMin, + xMax: b.xMax, + yMin: 0, + yMax: b.count, + })); + + return { data: { kind: 'histogram', points }, count: points.length }; +} + +/** + * Converts a single Victory data component into a {@link VictoryLayerInfo}. + */ +function extractLayerFromElement( + element: ReactElement, + layerId: number, + axisLabels: { x?: string; y?: string }, +): VictoryLayerInfo | null { + const name = getVictoryDisplayName(element.type); + if (!name || !isDataComponent(name)) + return null; + + const props = element.props as Record; + + let extracted: { data: VictoryLayerData; count: number } | null = null; + + switch (name) { + case 'VictoryBar': + case 'VictoryPie': + extracted = extractBarData(props); + break; + case 'VictoryLine': + case 'VictoryArea': + extracted = extractLineData(props); + break; + case 'VictoryScatter': + extracted = extractScatterData(props); + break; + case 'VictoryBoxPlot': + extracted = extractBoxData(props); + break; + case 'VictoryCandlestick': + extracted = extractCandlestickData(props); + break; + case 'VictoryHistogram': + extracted = extractHistogramData(props); + break; + } + + if (!extracted) + return null; + + return { + id: String(layerId), + victoryType: name, + data: extracted.data, + xAxisLabel: axisLabels.x, + yAxisLabel: axisLabels.y, + dataCount: extracted.count, + }; +} + +// --------------------------------------------------------------------------- +// Grouped / stacked bar extraction +// --------------------------------------------------------------------------- + +/** + * Extracts a segmented (grouped or stacked) bar layer from a VictoryGroup + * or VictoryStack container. + * + * Each child VictoryBar becomes one series (row) in the resulting + * `SegmentedPoint[][]` data. + */ +function extractSegmentedLayer( + containerElement: ReactElement, + containerType: 'VictoryGroup' | 'VictoryStack', + layerId: number, + axisLabels: { x?: string; y?: string }, +): VictoryLayerInfo | null { + const containerProps = containerElement.props as { children?: ReactNode }; + const series: SegmentedPoint[][] = []; + const legend: string[] = []; + let totalElements = 0; + + Children.forEach(containerProps.children, (child) => { + if (!isValidElement(child)) + return; + const name = getVictoryDisplayName(child.type); + if (name !== 'VictoryBar') + return; + + const props = child.props as Record; + const rawData = props.data; + if (!validateRawData(rawData)) + return; + + const getX = resolveAccessor(props.x, 'x'); + const getY = resolveAccessor(props.y, 'y'); + const seriesName = (props.name as string) ?? `Series ${series.length + 1}`; + + const points: SegmentedPoint[] = rawData.map(d => ({ + x: getX(d) as string | number, + y: getY(d) as number | string, + fill: seriesName, + })); + + series.push(points); + legend.push(seriesName); + totalElements += rawData.length; + }); + + if (series.length === 0) + return null; + + const traceType: VictoryComponentType = containerType; + + return { + id: String(layerId), + victoryType: traceType, + data: { kind: 'segmented', points: series }, + xAxisLabel: axisLabels.x, + yAxisLabel: axisLabels.y, + dataCount: totalElements, + legend, + }; +} + +// --------------------------------------------------------------------------- +// Tree walking +// --------------------------------------------------------------------------- + +/** + * Walks the React element tree to extract Victory data layers. + * + * Handles: + * - `` wrappers (processes children) + * - Standalone data components (e.g. ``) + * - `` and `` for segmented bar charts + */ +export function extractVictoryLayers(children: ReactNode): VictoryLayerInfo[] { + const layers: VictoryLayerInfo[] = []; + let layerId = 0; + + function processChildren(childNodes: ReactNode, axisLabels: { x?: string; y?: string }): void { + Children.forEach(childNodes, (child) => { + if (!isValidElement(child)) + return; + + const name = getVictoryDisplayName(child.type); + if (!name) + return; + + // VictoryGroup / VictoryStack → segmented bar + if (name === 'VictoryGroup' || name === 'VictoryStack') { + const segmented = extractSegmentedLayer(child, name, layerId, axisLabels); + if (segmented) { + layers.push(segmented); + layerId++; + } + return; + } + + // Individual data components + const layer = extractLayerFromElement(child, layerId, axisLabels); + if (layer) { + layers.push(layer); + layerId++; + } + }); + } + + Children.forEach(children, (child) => { + if (!isValidElement(child)) + return; + + const name = getVictoryDisplayName(child.type); + + if (name === 'VictoryChart') { + const chartProps = child.props as { children?: ReactNode }; + const axisLabels = extractAxisLabels(chartProps.children); + processChildren(chartProps.children, axisLabels); + } else { + processChildren(child, {}); + } + }); + + return layers; +} + +// --------------------------------------------------------------------------- +// MAIDR schema conversion +// --------------------------------------------------------------------------- + +/** + * Converts a {@link VictoryLayerInfo} into the MAIDR {@link MaidrLayer} + * schema. + * + * @param layer - Intermediate Victory layer info + * @param selector - CSS selector for the SVG elements (may be undefined if + * tagging was not possible) + */ +export function toMaidrLayer(layer: VictoryLayerInfo, selector?: string): MaidrLayer { + const axes: MaidrLayer['axes'] = { + x: layer.xAxisLabel, + y: layer.yAxisLabel, + }; + + const { data } = layer; + + switch (data.kind) { + case 'bar': + return { + id: layer.id, + type: TraceType.BAR, + axes, + selectors: selector, + data: data.points, + }; + + case 'line': + return { + id: layer.id, + type: TraceType.LINE, + axes, + selectors: selector ? [selector] : undefined, + data: data.points, + }; + + case 'scatter': + return { + id: layer.id, + type: TraceType.SCATTER, + axes, + selectors: selector, + data: data.points, + }; + + case 'box': + return { + id: layer.id, + type: TraceType.BOX, + axes, + data: data.points, + }; + + case 'candlestick': + return { + id: layer.id, + type: TraceType.CANDLESTICK, + axes, + data: data.points, + }; + + case 'histogram': + return { + id: layer.id, + type: TraceType.HISTOGRAM, + axes, + selectors: selector, + data: data.points, + }; + + case 'segmented': { + const traceType = layer.victoryType === 'VictoryStack' + ? TraceType.STACKED + : TraceType.DODGED; + + return { + id: layer.id, + type: traceType, + axes, + selectors: selector, + data: data.points, + }; + } + } +} diff --git a/src/victory/index.ts b/src/victory/index.ts new file mode 100644 index 00000000..bd2d05e7 --- /dev/null +++ b/src/victory/index.ts @@ -0,0 +1,39 @@ +/** + * Victory charting library adapter for MAIDR. + * + * Provides the `` component that wraps Victory chart components + * and automatically adds accessible, non-visual access through audio + * sonification, text descriptions, braille output, and keyboard navigation. + * + * @remarks + * Requires `victory` and React 18+ as peer dependencies. + * + * @example + * ```tsx + * import { MaidrVictory } from 'maidr/victory'; + * import { VictoryChart, VictoryBar, VictoryAxis } from 'victory'; + * + * function AccessibleChart() { + * return ( + * + * + * + * + * + * + * + * ); + * } + * ``` + * + * @packageDocumentation + */ +export { MaidrVictory } from './MaidrVictory'; +export type { MaidrVictoryProps } from './types'; diff --git a/src/victory/selectors.ts b/src/victory/selectors.ts new file mode 100644 index 00000000..3e868bc2 --- /dev/null +++ b/src/victory/selectors.ts @@ -0,0 +1,164 @@ +import type { VictoryLayerInfo } from './types'; + +/** + * Data-attribute prefix used to tag Victory-rendered SVG elements so that + * MAIDR's `Svg.selectAllElements()` can reliably select them later. + */ +const ATTR_PREFIX = 'data-maidr-victory'; + +/** + * Removes all `data-maidr-victory-*` attributes from elements inside the + * container. Must be called before re-tagging to prevent stale attributes + * from accumulating across re-renders. + */ +export function clearTaggedElements(container: HTMLElement): void { + const svg = container.querySelector('svg'); + if (!svg) + return; + + const tagged = svg.querySelectorAll(`[${ATTR_PREFIX}-0], [${ATTR_PREFIX}-1], [${ATTR_PREFIX}-2], [${ATTR_PREFIX}-3], [${ATTR_PREFIX}-4], [${ATTR_PREFIX}-5], [${ATTR_PREFIX}-6], [${ATTR_PREFIX}-7], [${ATTR_PREFIX}-8], [${ATTR_PREFIX}-9]`); + for (const el of tagged) { + const attrs = Array.from(el.attributes); + for (const attr of attrs) { + if (attr.name.startsWith(ATTR_PREFIX)) { + el.removeAttribute(attr.name); + } + } + } +} + +/** + * Finds the Victory data elements inside a container for a given layer, + * tags them with a unique data attribute, and returns the CSS selector + * that matches exactly those elements. + * + * Strategy: + * 1. Victory renders each data component's elements with + * `role="presentation"` on the individual data shapes. + * 2. We query for the expected SVG tag (e.g. `rect` for bars) that + * carries `role="presentation"`. + * 3. We skip elements that belong to other layers by tracking which + * elements were already claimed. + * 4. Each claimed element receives a `data-maidr-victory-` + * attribute so MAIDR can select them deterministically. + * + * @remarks + * This relies on Victory's `role="presentation"` attribute on data + * elements, which is a stable Victory convention (tested with v37). + * If Victory changes this convention, selector tagging will silently + * degrade and highlighting will stop working (audio/text/braille are + * unaffected). + * + * @param container - The DOM node wrapping the Victory chart + * @param layer - The extracted layer info + * @param layerIndex - Numeric index for generating unique attribute names + * @param claimed - Set of elements already claimed by prior layers + * @returns A CSS selector string, or `undefined` if elements could not be + * matched (highlighting will gracefully degrade). + */ +export function tagLayerElements( + container: HTMLElement, + layer: VictoryLayerInfo, + layerIndex: number, + claimed: Set, +): string | undefined { + const svg = container.querySelector('svg'); + if (!svg) + return undefined; + + const attrName = `${ATTR_PREFIX}-${layerIndex}`; + const { victoryType } = layer; + + // Line and area charts: single representing the full series + if (victoryType === 'VictoryLine' || victoryType === 'VictoryArea') { + return tagLineOrAreaElements(svg, layer, attrName, claimed); + } + + // Box, candlestick: complex multi-element compositions that don't map + // cleanly to a single selector strategy. Skip selector tagging so that + // MAIDR's audio/text/braille still work (highlighting degrades). + if (victoryType === 'VictoryBoxPlot' || victoryType === 'VictoryCandlestick') { + return undefined; + } + + // Discrete-element charts: one SVG element per data point. + // VictoryBar and VictoryHistogram render elements. + // VictoryScatter, VictoryPie, VictoryGroup, and VictoryStack render + // elements (VictoryPie uses for arc slices, not , + // despite being mapped to TraceType.BAR for data representation). + const tag = victoryType === 'VictoryBar' || victoryType === 'VictoryHistogram' + ? 'rect' + : 'path'; + + return tagDiscreteElements(svg, tag, layer, attrName, claimed); +} + +/** + * Tags discrete SVG elements (one element per data point) for bar, + * histogram, scatter, pie, and segmented (stacked/dodged) charts. + */ +function tagDiscreteElements( + svg: SVGElement, + tag: string, + layer: VictoryLayerInfo, + attrName: string, + claimed: Set, +): string | undefined { + const candidates = Array.from( + svg.querySelectorAll(`${tag}[role="presentation"]`), + ).filter(el => !claimed.has(el)); + + // Victory renders exactly one element per data point. + // Take the first `dataCount` unclaimed elements. + const matched = candidates.slice(0, layer.dataCount); + + if (matched.length !== layer.dataCount) { + return undefined; + } + + for (const el of matched) { + el.setAttribute(attrName, ''); + claimed.add(el); + } + + return `[${attrName}]`; +} + +/** + * Tags the path element for a VictoryLine or VictoryArea layer. + * + * Victory renders a single `` with `role="presentation"` for + * the line/area. MAIDR's line trace parses the `d` attribute to derive + * individual point positions. + */ +function tagLineOrAreaElements( + svg: SVGElement, + layer: VictoryLayerInfo, + attrName: string, + claimed: Set, +): string | undefined { + const candidates = Array.from( + svg.querySelectorAll('path[role="presentation"]'), + ).filter(el => !claimed.has(el)); + + if (candidates.length === 0) + return undefined; + + // Heuristic: pick the first path whose `d` attribute contains enough + // SVG commands to plausibly represent the data points. + for (const candidate of candidates) { + const d = candidate.getAttribute('d') ?? ''; + const commandCount = (d.match(/[MLCQTSA]/gi) ?? []).length; + if (commandCount >= layer.dataCount) { + candidate.setAttribute(attrName, ''); + claimed.add(candidate); + return `[${attrName}]`; + } + } + + // Fallback: use the first unclaimed path. + const fallback = candidates[0]; + fallback.setAttribute(attrName, ''); + claimed.add(fallback); + return `[${attrName}]`; +} diff --git a/src/victory/types.ts b/src/victory/types.ts new file mode 100644 index 00000000..bd5375bd --- /dev/null +++ b/src/victory/types.ts @@ -0,0 +1,78 @@ +import type { + BarPoint, + BoxPoint, + CandlestickPoint, + HistogramPoint, + LinePoint, + ScatterPoint, + SegmentedPoint, +} from '@type/grammar'; +import type { ReactNode } from 'react'; + +/** + * Props for the MaidrVictory component. + */ +export interface MaidrVictoryProps { + /** Unique identifier for the chart. Used for DOM element IDs. */ + id: string; + /** Chart title displayed in text descriptions. */ + title?: string; + /** Chart subtitle. */ + subtitle?: string; + /** Chart caption. */ + caption?: string; + /** Victory chart components to make accessible. */ + children: ReactNode; +} + +/** + * Victory chart component types that MAIDR can extract data from. + */ +export type VictoryComponentType + = | 'VictoryBar' + | 'VictoryLine' + | 'VictoryScatter' + | 'VictoryArea' + | 'VictoryPie' + | 'VictoryBoxPlot' + | 'VictoryCandlestick' + | 'VictoryHistogram' + | 'VictoryGroup' + | 'VictoryStack'; + +/** + * Discriminated union of all supported layer data shapes. + * + * Using a discriminated union instead of `unknown` ensures that each + * layer's data is validated at extraction time and carries the correct + * type through to the MAIDR schema conversion. + */ +export type VictoryLayerData + = | { kind: 'bar'; points: BarPoint[] } + | { kind: 'line'; points: LinePoint[][] } + | { kind: 'scatter'; points: ScatterPoint[] } + | { kind: 'box'; points: BoxPoint[] } + | { kind: 'candlestick'; points: CandlestickPoint[] } + | { kind: 'histogram'; points: HistogramPoint[] } + | { kind: 'segmented'; points: SegmentedPoint[][] }; + +/** + * Intermediate representation of a Victory data layer before conversion + * to the MAIDR schema. + */ +export interface VictoryLayerInfo { + /** Index-based layer ID. */ + id: string; + /** The Victory component type that produced this layer. */ + victoryType: VictoryComponentType; + /** Extracted and validated data points from the Victory component. */ + data: VictoryLayerData; + /** X-axis label (from VictoryAxis or fallback). */ + xAxisLabel?: string; + /** Y-axis label (from VictoryAxis or fallback). */ + yAxisLabel?: string; + /** Number of data elements expected in the DOM (used for selector validation). */ + dataCount: number; + /** Legend labels for multi-series segmented charts. */ + legend?: string[]; +} diff --git a/vite.victory.config.ts b/vite.victory.config.ts new file mode 100644 index 00000000..27b32721 --- /dev/null +++ b/vite.victory.config.ts @@ -0,0 +1,53 @@ +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/victory/index.ts'), + formats: ['es'], + fileName: () => 'victory.mjs', + }, + sourcemap: true, + outDir: 'dist', + emptyOutDir: false, + rollupOptions: { + external: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'victory', + ], + 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'), + }, + }, +});