diff --git a/examples/d3-bindbar.html b/examples/d3-bindbar.html new file mode 100644 index 00000000..9a5e94b8 --- /dev/null +++ b/examples/d3-bindbar.html @@ -0,0 +1,108 @@ + + + + + MAIDR + D3.js Bar Chart Example + + + + + +

MAIDR + D3.js Bar Chart

+

Click on the chart and use arrow keys to navigate. Press 'b' for braille, 't' for text descriptions.

+
+ + + + diff --git a/examples/d3-bindbox.html b/examples/d3-bindbox.html new file mode 100644 index 00000000..14a0af3e --- /dev/null +++ b/examples/d3-bindbox.html @@ -0,0 +1,83 @@ + + + + + MAIDR + D3.js Box Plot Example + + + + + +

MAIDR + D3.js Box Plot

+

Click on the chart and use arrow keys to navigate between boxes.

+
+ + + + diff --git a/examples/d3-bindcandlestick.html b/examples/d3-bindcandlestick.html new file mode 100644 index 00000000..6890f774 --- /dev/null +++ b/examples/d3-bindcandlestick.html @@ -0,0 +1,81 @@ + + + + + MAIDR + D3.js Candlestick Chart Example + + + + + +

MAIDR + D3.js Candlestick Chart

+

Click on the chart and use arrow keys to navigate between trading days.

+
+ + + + diff --git a/examples/d3-binddodged.html b/examples/d3-binddodged.html new file mode 100644 index 00000000..afd5c5be --- /dev/null +++ b/examples/d3-binddodged.html @@ -0,0 +1,76 @@ + + + + + MAIDR + D3.js Dodged Bar Chart Example + + + + + +

MAIDR + D3.js Dodged (Grouped) Bar Chart

+

Click on the chart and use arrow keys to navigate bars.

+
+ + + + diff --git a/examples/d3-bindheatmap.html b/examples/d3-bindheatmap.html new file mode 100644 index 00000000..9651890c --- /dev/null +++ b/examples/d3-bindheatmap.html @@ -0,0 +1,111 @@ + + + + + MAIDR + D3.js Heatmap Example + + + + + +

MAIDR + D3.js Heatmap

+

Click on the chart and use arrow keys to navigate cells.

+
+ + + + diff --git a/examples/d3-bindhistogram.html b/examples/d3-bindhistogram.html new file mode 100644 index 00000000..2de3ab0a --- /dev/null +++ b/examples/d3-bindhistogram.html @@ -0,0 +1,108 @@ + + + + + MAIDR + D3.js Histogram Example + + + + + +

MAIDR + D3.js Histogram

+

Click on the chart and use arrow keys to navigate between bins.

+
+ + + + diff --git a/examples/d3-bindline.html b/examples/d3-bindline.html new file mode 100644 index 00000000..6dc3e00f --- /dev/null +++ b/examples/d3-bindline.html @@ -0,0 +1,144 @@ + + + + + MAIDR + D3.js Line Chart Example + + + + + +

MAIDR + D3.js Line Chart

+

Click on the chart and use arrow keys to navigate along the line.

+
+ + + + diff --git a/examples/d3-bindscatter.html b/examples/d3-bindscatter.html new file mode 100644 index 00000000..c684ab9a --- /dev/null +++ b/examples/d3-bindscatter.html @@ -0,0 +1,112 @@ + + + + + MAIDR + D3.js Scatter Plot Example + + + + + +

MAIDR + D3.js Scatter Plot

+

Click on the chart and use arrow keys to navigate between data points.

+
+ + + + diff --git a/examples/d3-bindsmooth.html b/examples/d3-bindsmooth.html new file mode 100644 index 00000000..c78c1a63 --- /dev/null +++ b/examples/d3-bindsmooth.html @@ -0,0 +1,84 @@ + + + + + MAIDR + D3.js Smooth Curve Example + + + + + +

MAIDR + D3.js Smooth / Regression Curve

+

Click on the chart and use arrow keys to navigate along the fitted curve.

+
+ + + + diff --git a/examples/d3-bindstacked.html b/examples/d3-bindstacked.html new file mode 100644 index 00000000..ef4267e6 --- /dev/null +++ b/examples/d3-bindstacked.html @@ -0,0 +1,100 @@ + + + + + MAIDR + D3.js Stacked Bar Chart Example + + + + + +

MAIDR + D3.js Stacked Bar Chart

+

Click on the chart and use arrow keys to navigate segments.

+
+ + + + 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..f67df592 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,11 @@ "types": "./dist/react.d.mts", "import": "./dist/react.mjs", "default": "./dist/react.mjs" + }, + "./d3": { + "types": "./dist/d3.d.mts", + "import": "./dist/d3.mjs", + "default": "./dist/d3.js" } }, "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.d3.config.ts", "build:script": "vite build", "build:react": "vite build --config vite.react.config.ts", + "build:d3": "vite build --config vite.d3.config.ts", "prepublishOnly": "npm run build", "prepare": "husky", "commitlint": "commitlint --from=HEAD~1 --to=HEAD", diff --git a/src/d3/bindBar.ts b/src/d3/bindBar.ts new file mode 100644 index 00000000..afaf737a --- /dev/null +++ b/src/d3/bindBar.ts @@ -0,0 +1,97 @@ +/** + * D3 binder for bar charts. + * + * Extracts data from D3.js-rendered bar chart SVG elements and generates + * the MAIDR JSON schema for accessible bar chart interaction. + */ + +import type { BarPoint, Maidr, MaidrLayer } from '../type/grammar'; +import type { D3BarConfig, D3BinderResult } from './types'; +import { Orientation, TraceType } from '../type/grammar'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; + +/** + * Binds a D3.js bar chart to MAIDR, generating the accessible data representation. + * + * Extracts data from D3-bound SVG elements (``, ``, etc.) and + * produces a complete {@link Maidr} data structure for sonification, text + * descriptions, braille output, and keyboard navigation. + * + * @param svg - The SVG element (or container) containing the D3 bar chart. + * @param config - Configuration specifying the selector and data accessors. + * @returns A {@link D3BinderResult} with the MAIDR data and generated layer. + * + * @example + * ```ts + * // D3 bar chart with data bound to elements + * const result = bindD3Bar(svgElement, { + * selector: 'rect.bar', + * title: 'Sales by Quarter', + * axes: { x: 'Quarter', y: 'Revenue' }, + * x: 'quarter', // property name on the bound datum + * y: 'revenue', // property name on the bound datum + * }); + * + * // Use with maidr-data attribute + * svgElement.setAttribute('maidr-data', JSON.stringify(result.maidr)); + * + * // Or use with React + * ... + * ``` + */ +export function bindD3Bar(svg: Element, config: D3BarConfig): D3BinderResult { + const { + id = generateId(), + title, + subtitle, + caption, + axes, + format, + selector, + x: xAccessor = 'x', + y: yAccessor = 'y', + orientation = Orientation.VERTICAL, + } = config; + + const elements = queryD3Elements(svg, selector); + if (elements.length === 0) { + throw new Error( + `No elements found for selector "${selector}". ` + + `Ensure the D3 chart has been rendered and the selector matches the bar elements.`, + ); + } + + const data: BarPoint[] = elements.map(({ datum, index }) => { + if (!datum) { + throw new Error( + `No D3 data bound to element at index ${index}. ` + + `Ensure D3's .data() join has been applied to the "${selector}" elements.`, + ); + } + return { + x: resolveAccessor(datum, xAccessor, index), + y: resolveAccessor(datum, yAccessor, index), + }; + }); + + const layerId = generateId(); + const layer: MaidrLayer = { + id: layerId, + type: TraceType.BAR, + title, + selectors: scopeSelector(svg, selector), + orientation, + axes: buildAxes(axes, format), + data, + }; + + const maidr: Maidr = { + id, + title, + subtitle, + caption, + subplots: [[{ layers: [layer] }]], + }; + + return { maidr, layer }; +} diff --git a/src/d3/bindBox.ts b/src/d3/bindBox.ts new file mode 100644 index 00000000..541cda01 --- /dev/null +++ b/src/d3/bindBox.ts @@ -0,0 +1,125 @@ +/** + * D3 binder for box plots. + * + * Extracts data from D3.js-rendered box plot SVG elements and generates + * the MAIDR JSON schema for accessible box plot interaction. + */ + +import type { BoxPoint, Maidr, MaidrLayer } from '../type/grammar'; +import type { D3BinderResult, D3BoxConfig } from './types'; +import { Orientation, TraceType } from '../type/grammar'; +import { buildAxes, generateId, getD3Datum, queryD3Elements, resolveAccessor, resolveAccessorOptional, scopeSelector } from './util'; + +/** + * Binds a D3.js box plot to MAIDR, generating the accessible data representation. + * + * Box plots in D3 are typically constructed from multiple SVG elements per box + * (a rect for the IQR, lines for whiskers, a line for the median, and circles + * for outliers). This binder extracts statistical summary data from D3-bound + * data on the box group elements. + * + * @param svg - The SVG element containing the D3 box plot. + * @param config - Configuration specifying selectors and data accessors. + * @returns A {@link D3BinderResult} with the MAIDR data and generated layer. + * + * @example + * ```ts + * const result = bindD3Box(svgElement, { + * selector: 'g.box', + * title: 'Distribution by Category', + * axes: { x: 'Category', y: 'Value' }, + * fill: 'category', + * min: 'whiskerLow', + * q1: 'q1', + * q2: 'median', + * q3: 'q3', + * max: 'whiskerHigh', + * lowerOutliers: 'lowOutliers', + * upperOutliers: 'highOutliers', + * }); + * ``` + */ +export function bindD3Box(svg: Element, config: D3BoxConfig): D3BinderResult { + const { + id = generateId(), + title, + subtitle, + caption, + axes, + format, + selector, + fill: fillAccessor = 'fill', + min: minAccessor = 'min', + q1: q1Accessor = 'q1', + q2: q2Accessor = 'q2', + q3: q3Accessor = 'q3', + max: maxAccessor = 'max', + lowerOutliers: lowerOutliersAccessor = 'lowerOutliers', + upperOutliers: upperOutliersAccessor = 'upperOutliers', + orientation = Orientation.VERTICAL, + } = config; + + const boxGroups = queryD3Elements(svg, selector); + if (boxGroups.length === 0) { + throw new Error( + `No elements found for selector "${selector}". ` + + `Ensure the D3 chart has been rendered and the selector matches the box group elements.`, + ); + } + + const data: BoxPoint[] = boxGroups.map(({ element, datum, index }) => { + // Try to get data from the group element's D3 binding first + let effectiveDatum = datum; + + // If no data on the group, try to find it on child elements + if (!effectiveDatum) { + const firstChild = element.querySelector('rect, line, path'); + if (firstChild) { + effectiveDatum = getD3Datum(firstChild); + } + } + + if (!effectiveDatum) { + throw new Error( + `No D3 data bound to box group element at index ${index}. ` + + `Ensure D3's .data() join has been applied to the "${selector}" elements.`, + ); + } + + // Outlier arrays are optional - use resolveAccessorOptional + const lowerOutliers = resolveAccessorOptional(effectiveDatum, lowerOutliersAccessor, index) ?? []; + const upperOutliers = resolveAccessorOptional(effectiveDatum, upperOutliersAccessor, index) ?? []; + + return { + fill: resolveAccessor(effectiveDatum, fillAccessor, index), + lowerOutliers, + min: resolveAccessor(effectiveDatum, minAccessor, index), + q1: resolveAccessor(effectiveDatum, q1Accessor, index), + q2: resolveAccessor(effectiveDatum, q2Accessor, index), + q3: resolveAccessor(effectiveDatum, q3Accessor, index), + max: resolveAccessor(effectiveDatum, maxAccessor, index), + upperOutliers, + }; + }); + + const layerId = generateId(); + const layer: MaidrLayer = { + id: layerId, + type: TraceType.BOX, + title, + selectors: scopeSelector(svg, selector), + orientation, + axes: buildAxes(axes, format), + data, + }; + + const maidr: Maidr = { + id, + title, + subtitle, + caption, + subplots: [[{ layers: [layer] }]], + }; + + return { maidr, layer }; +} diff --git a/src/d3/bindCandlestick.ts b/src/d3/bindCandlestick.ts new file mode 100644 index 00000000..7d161219 --- /dev/null +++ b/src/d3/bindCandlestick.ts @@ -0,0 +1,123 @@ +/** + * D3 binder for candlestick charts. + * + * Extracts data from D3.js-rendered candlestick chart SVG elements and generates + * the MAIDR JSON schema for accessible candlestick chart interaction. + */ + +import type { CandlestickPoint, CandlestickTrend, Maidr, MaidrLayer } from '../type/grammar'; +import type { D3BinderResult, D3CandlestickConfig } from './types'; +import { TraceType } from '../type/grammar'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, resolveAccessorOptional, scopeSelector } from './util'; + +/** + * Binds a D3.js candlestick chart to MAIDR. + * + * Candlestick charts show OHLC (Open, High, Low, Close) data for financial + * time series. This binder extracts data from D3-bound SVG elements + * representing candlestick bodies (typically ``) and optional wicks. + * + * @param svg - The SVG element containing the D3 candlestick chart. + * @param config - Configuration specifying the selector and data accessors. + * @returns A {@link D3BinderResult} with the MAIDR data and generated layer. + * + * @example + * ```ts + * const result = bindD3Candlestick(svgElement, { + * selector: 'rect.candle', + * title: 'Stock Price', + * axes: { x: 'Date', y: 'Price ($)' }, + * value: 'date', + * open: 'open', + * high: 'high', + * low: 'low', + * close: 'close', + * volume: 'volume', + * }); + * ``` + */ +export function bindD3Candlestick(svg: Element, config: D3CandlestickConfig): D3BinderResult { + const { + id = generateId(), + title, + subtitle, + caption, + axes, + format, + selector, + value: valueAccessor = 'value', + open: openAccessor = 'open', + high: highAccessor = 'high', + low: lowAccessor = 'low', + close: closeAccessor = 'close', + volume: volumeAccessor = 'volume', + trend: trendAccessor, + } = config; + + const elements = queryD3Elements(svg, selector); + if (elements.length === 0) { + throw new Error( + `No elements found for selector "${selector}". ` + + `Ensure the D3 chart has been rendered and the selector matches the candlestick elements.`, + ); + } + + const data: CandlestickPoint[] = elements.map(({ datum, index }) => { + if (!datum) { + throw new Error( + `No D3 data bound to element at index ${index}. ` + + `Ensure D3's .data() join has been applied to the "${selector}" elements.`, + ); + } + + const openVal = resolveAccessor(datum, openAccessor, index); + const closeVal = resolveAccessor(datum, closeAccessor, index); + const highVal = resolveAccessor(datum, highAccessor, index); + const lowVal = resolveAccessor(datum, lowAccessor, index); + + // Compute trend if not provided + let trend: CandlestickTrend; + if (trendAccessor) { + trend = resolveAccessor(datum, trendAccessor, index); + } else if (closeVal > openVal) { + trend = 'Bull'; + } else if (closeVal < openVal) { + trend = 'Bear'; + } else { + trend = 'Neutral'; + } + + const volumeVal = resolveAccessorOptional(datum, volumeAccessor, index) ?? 0; + + return { + value: resolveAccessor(datum, valueAccessor, index), + open: openVal, + high: highVal, + low: lowVal, + close: closeVal, + volume: volumeVal, + trend, + volatility: highVal - lowVal, + }; + }); + + const layerId = generateId(); + const layer: MaidrLayer = { + id: layerId, + type: TraceType.CANDLESTICK, + title, + selectors: scopeSelector(svg, selector), + axes: buildAxes(axes, format), + data, + }; + + const maidr: Maidr = { + id, + title, + subtitle, + caption, + subplots: [[{ layers: [layer] }]], + }; + + return { maidr, layer }; +} diff --git a/src/d3/bindHeatmap.ts b/src/d3/bindHeatmap.ts new file mode 100644 index 00000000..ab2464ee --- /dev/null +++ b/src/d3/bindHeatmap.ts @@ -0,0 +1,145 @@ +/** + * D3 binder for heatmaps. + * + * Extracts data from D3.js-rendered heatmap SVG elements and generates + * the MAIDR JSON schema for accessible heatmap interaction. + */ + +import type { HeatmapData, Maidr, MaidrLayer } from '../type/grammar'; +import type { D3BinderResult, D3HeatmapConfig } from './types'; +import { TraceType } from '../type/grammar'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; + +/** + * Binds a D3.js heatmap to MAIDR, generating the accessible data representation. + * + * Extracts cell data from D3-bound SVG elements (``) organized in a grid + * and produces a complete {@link Maidr} data structure. The cells are grouped + * by their x and y category values to form the 2D points grid. + * + * @param svg - The SVG element containing the D3 heatmap. + * @param config - Configuration specifying the selector and data accessors. + * @returns A {@link D3BinderResult} with the MAIDR data and generated layer. + * @throws Error if any cell coordinate pair is missing from the extracted data. + * + * @example + * ```ts + * const result = bindD3Heatmap(svgElement, { + * selector: 'rect.cell', + * title: 'Correlation Matrix', + * axes: { x: 'Variable', y: 'Variable', fill: 'Correlation' }, + * x: 'xVar', + * y: 'yVar', + * value: 'correlation', + * }); + * ``` + */ +export function bindD3Heatmap(svg: Element, config: D3HeatmapConfig): D3BinderResult { + const { + id = generateId(), + title, + subtitle, + caption, + axes, + format, + selector, + x: xAccessor = 'x', + y: yAccessor = 'y', + value: valueAccessor = 'value', + } = config; + + const elements = queryD3Elements(svg, selector); + if (elements.length === 0) { + throw new Error( + `No elements found for selector "${selector}". ` + + `Ensure the D3 chart has been rendered and the selector matches the cell elements.`, + ); + } + + // Extract raw cell data + const cells: { x: string; y: string; value: number }[] = elements.map(({ datum, index }) => { + if (!datum) { + throw new Error( + `No D3 data bound to element at index ${index}. ` + + `Ensure D3's .data() join has been applied to the "${selector}" elements.`, + ); + } + return { + x: String(resolveAccessor(datum, xAccessor, index)), + y: String(resolveAccessor(datum, yAccessor, index)), + value: resolveAccessor(datum, valueAccessor, index), + }; + }); + + // Build unique x and y labels (preserving order of appearance) + const xLabels: string[] = []; + const yLabels: string[] = []; + const seenX = new Set(); + const seenY = new Set(); + + for (const cell of cells) { + if (!seenX.has(cell.x)) { + seenX.add(cell.x); + xLabels.push(cell.x); + } + if (!seenY.has(cell.y)) { + seenY.add(cell.y); + yLabels.push(cell.y); + } + } + + // Build the 2D points grid using nested Maps to avoid key collisions + const cellMap = new Map>(); + for (const cell of cells) { + let row = cellMap.get(cell.y); + if (!row) { + row = new Map(); + cellMap.set(cell.y, row); + } + row.set(cell.x, cell.value); + } + + const points: number[][] = []; + for (const yLabel of yLabels) { + const row: number[] = []; + const rowMap = cellMap.get(yLabel); + for (const xLabel of xLabels) { + const value = rowMap?.get(xLabel); + if (value === undefined) { + throw new Error( + `Missing heatmap cell for y="${yLabel}", x="${xLabel}". ` + + `Expected a complete grid of ${yLabels.length} x ${xLabels.length} cells ` + + `but found ${cells.length} elements.`, + ); + } + row.push(value); + } + points.push(row); + } + + const data: HeatmapData = { + x: xLabels, + y: yLabels, + points, + }; + + const layerId = generateId(); + const layer: MaidrLayer = { + id: layerId, + type: TraceType.HEATMAP, + title, + selectors: scopeSelector(svg, selector), + axes: buildAxes(axes, format), + data, + }; + + const maidr: Maidr = { + id, + title, + subtitle, + caption, + subplots: [[{ layers: [layer] }]], + }; + + return { maidr, layer }; +} diff --git a/src/d3/bindHistogram.ts b/src/d3/bindHistogram.ts new file mode 100644 index 00000000..c00685e3 --- /dev/null +++ b/src/d3/bindHistogram.ts @@ -0,0 +1,113 @@ +/** + * D3 binder for histograms. + * + * Extracts data from D3.js-rendered histogram SVG elements and generates + * the MAIDR JSON schema for accessible histogram interaction. + */ + +import type { HistogramPoint, Maidr, MaidrLayer } from '../type/grammar'; +import type { D3BinderResult, D3HistogramConfig } from './types'; +import { TraceType } from '../type/grammar'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; + +/** + * Binds a D3.js histogram to MAIDR, generating the accessible data representation. + * + * D3 histograms are typically created with `d3.bin()` (or `d3.histogram()` in v5), + * which produces arrays with `x0` and `x1` properties for bin boundaries. This + * binder extracts bin data from D3-bound rect elements. + * + * @param svg - The SVG element containing the D3 histogram. + * @param config - Configuration specifying the selector and data accessors. + * @returns A {@link D3BinderResult} with the MAIDR data and generated layer. + * + * @example + * ```ts + * // D3 histogram using d3.bin() + * const result = bindD3Histogram(svgElement, { + * selector: 'rect.bar', + * title: 'Age Distribution', + * axes: { x: 'Age', y: 'Count' }, + * x: (d) => `${d.x0}-${d.x1}`, + * y: (d) => d.length, + * xMin: 'x0', + * xMax: 'x1', + * yMin: () => 0, + * yMax: (d) => d.length, + * }); + * ``` + */ +export function bindD3Histogram(svg: Element, config: D3HistogramConfig): D3BinderResult { + const { + id = generateId(), + title, + subtitle, + caption, + axes, + format, + selector, + x: xAccessor = 'x', + y: yAccessor = 'y', + xMin: xMinAccessor = 'x0', + xMax: xMaxAccessor = 'x1', + yMin: yMinAccessor = (_d: unknown, _i: number) => 0, + yMax: yMaxAccessor, + } = config; + + const elements = queryD3Elements(svg, selector); + if (elements.length === 0) { + throw new Error( + `No elements found for selector "${selector}". ` + + `Ensure the D3 chart has been rendered and the selector matches the histogram bar elements.`, + ); + } + + const data: HistogramPoint[] = elements.map(({ datum, index }) => { + if (!datum) { + throw new Error( + `No D3 data bound to element at index ${index}. ` + + `Ensure D3's .data() join has been applied to the "${selector}" elements.`, + ); + } + + // For D3 bin data, the datum is typically an array with x0/x1 properties. + // The "y" value is usually the array length (count of items in the bin). + const xValue = resolveAccessor(datum, xAccessor, index); + const yValue = resolveAccessor(datum, yAccessor, index); + const xMin = resolveAccessor(datum, xMinAccessor, index); + const xMax = resolveAccessor(datum, xMaxAccessor, index); + const yMin = resolveAccessor(datum, yMinAccessor, index); + const yMax = yMaxAccessor + ? resolveAccessor(datum, yMaxAccessor, index) + : Number(yValue); + + return { + x: xValue, + y: yValue, + xMin, + xMax, + yMin, + yMax, + }; + }); + + const layerId = generateId(); + const layer: MaidrLayer = { + id: layerId, + type: TraceType.HISTOGRAM, + title, + selectors: scopeSelector(svg, selector), + axes: buildAxes(axes, format), + data, + }; + + const maidr: Maidr = { + id, + title, + subtitle, + caption, + subplots: [[{ layers: [layer] }]], + }; + + return { maidr, layer }; +} diff --git a/src/d3/bindLine.ts b/src/d3/bindLine.ts new file mode 100644 index 00000000..38952c6b --- /dev/null +++ b/src/d3/bindLine.ts @@ -0,0 +1,223 @@ +/** + * D3 binder for line charts. + * + * Extracts data from D3.js-rendered line chart SVG elements and generates + * the MAIDR JSON schema for accessible line chart interaction. + */ + +import type { LinePoint, Maidr, MaidrLayer } from '../type/grammar'; +import type { D3BinderResult, D3LineConfig, DataAccessor } from './types'; +import { TraceType } from '../type/grammar'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, resolveAccessorOptional, scopeSelector } from './util'; + +/** + * Binds a D3.js line chart to MAIDR, generating the accessible data representation. + * + * Supports both single-line and multi-line charts. Data can be extracted from: + * 1. D3-bound data on point elements (circles, etc.) via `pointSelector`. + * When using `pointSelector`, each line path and its associated points + * must share the same parent `` group element for correct scoping. + * 2. D3-bound data on the path elements themselves (array of points per path). + * + * @param svg - The SVG element containing the D3 line chart. + * @param config - Configuration specifying selectors and data accessors. + * @returns A {@link D3BinderResult} with the MAIDR data and generated layer. + * + * @example + * ```ts + * // Multi-line chart with paths and point circles + * const result = bindD3Line(svgElement, { + * selector: 'path.line', + * pointSelector: 'circle.data-point', + * title: 'Temperature Over Time', + * axes: { x: 'Month', y: 'Temperature (F)' }, + * x: 'month', + * y: 'temp', + * fill: 'city', + * }); + * ``` + */ +export function bindD3Line(svg: Element, config: D3LineConfig): D3BinderResult { + const { + id = generateId(), + title, + subtitle, + caption, + axes, + format, + selector, + pointSelector, + x: xAccessor = 'x', + y: yAccessor = 'y', + fill: fillAccessor = 'fill', + } = config; + + const lineElements = queryD3Elements(svg, selector); + if (lineElements.length === 0) { + throw new Error( + `No elements found for selector "${selector}". ` + + `Ensure the D3 chart has been rendered and the selector matches the line path elements.`, + ); + } + + const data: LinePoint[][] = []; + + if (pointSelector) { + // Determine whether line paths have distinct parent elements. + // Pattern A: Each lives in its own with its points. + // Pattern B: All s and s share a single parent . + const parents = new Set( + lineElements.map(({ element }) => element.parentElement ?? svg), + ); + + if (parents.size >= lineElements.length) { + // Pattern A: distinct parents – scope point queries per parent + for (const { element } of lineElements) { + const parent = element.parentElement ?? svg; + const points = queryD3Elements(parent, pointSelector); + const lineData = extractPointsFromElements( + points, + xAccessor, + yAccessor, + fillAccessor, + pointSelector, + ); + if (lineData.length > 0) { + data.push(lineData); + } + } + } else { + // Pattern B: shared parent – query all points once and group by fill + const allPoints = queryD3Elements(svg, pointSelector); + if (allPoints.length === 0) { + throw new Error( + `No point elements found for selector "${pointSelector}" within the SVG.`, + ); + } + + const lineMap = new Map(); + const lineOrder: string[] = []; + + for (const { datum, index } of allPoints) { + if (!datum) { + throw new Error( + `No D3 data bound to point element at index ${index}. ` + + `Ensure D3's .data() join has been applied to the "${pointSelector}" elements.`, + ); + } + const point: LinePoint = { + x: resolveAccessor(datum, xAccessor, index), + y: resolveAccessor(datum, yAccessor, index), + }; + const fill = resolveAccessorOptional(datum, fillAccessor, index); + if (fill !== undefined) { + point.fill = fill; + } + + const key = fill ?? '__default__'; + if (!lineMap.has(key)) { + lineOrder.push(key); + lineMap.set(key, []); + } + lineMap.get(key)!.push(point); + } + + for (const key of lineOrder) { + data.push(lineMap.get(key)!); + } + } + } else { + // Extract data from the path element's bound data directly + // D3 line charts typically bind an array of points to each path + for (const { datum } of lineElements) { + if (!datum) { + throw new Error( + `No D3 data bound to line path element. ` + + `Ensure D3's .data() join has been applied to the "${selector}" elements, ` + + `or provide a pointSelector.`, + ); + } + + const pointArray = Array.isArray(datum) ? datum : [datum]; + const lineData: LinePoint[] = pointArray.map((d: unknown, index: number) => { + const point: LinePoint = { + x: resolveAccessor(d, xAccessor, index), + y: resolveAccessor(d, yAccessor, index), + }; + const fill = resolveAccessorOptional(d, fillAccessor, index); + if (fill !== undefined) { + point.fill = fill; + } + return point; + }); + + if (lineData.length > 0) { + data.push(lineData); + } + } + } + + // Extract legend labels from fill values + const legend: string[] = []; + for (const lineData of data) { + const fill = lineData[0]?.fill; + if (fill) { + legend.push(fill); + } + } + + const layerId = generateId(); + const selectorValue = scopeSelector(svg, selector); + const layer: MaidrLayer = { + id: layerId, + type: TraceType.LINE, + title, + selectors: selectorValue, + axes: buildAxes(axes, format), + data, + }; + + const maidr: Maidr = { + id, + title, + subtitle, + caption, + subplots: [[{ + ...(legend.length > 0 ? { legend } : {}), + layers: [layer], + }]], + }; + + return { maidr, layer }; +} + +/** + * Extracts LinePoint data from a set of queried D3 elements. + */ +function extractPointsFromElements( + points: { element: Element; datum: unknown; index: number }[], + xAccessor: DataAccessor, + yAccessor: DataAccessor, + fillAccessor: DataAccessor, + pointSelector: string, +): LinePoint[] { + const lineData: LinePoint[] = []; + for (const { datum, index } of points) { + if (!datum) { + throw new Error( + `No D3 data bound to point element at index ${index}. ` + + `Ensure D3's .data() join has been applied to the "${pointSelector}" elements.`, + ); + } + const point: LinePoint = { + x: resolveAccessor(datum, xAccessor, index), + y: resolveAccessor(datum, yAccessor, index), + }; + const fill = resolveAccessorOptional(datum, fillAccessor, index); + if (fill !== undefined) { + point.fill = fill; + } + lineData.push(point); + } + return lineData; +} diff --git a/src/d3/bindScatter.ts b/src/d3/bindScatter.ts new file mode 100644 index 00000000..cca34f5c --- /dev/null +++ b/src/d3/bindScatter.ts @@ -0,0 +1,87 @@ +/** + * D3 binder for scatter plots. + * + * Extracts data from D3.js-rendered scatter plot SVG elements and generates + * the MAIDR JSON schema for accessible scatter plot interaction. + */ + +import type { Maidr, MaidrLayer, ScatterPoint } from '../type/grammar'; +import type { D3BinderResult, D3ScatterConfig } from './types'; +import { TraceType } from '../type/grammar'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; + +/** + * Binds a D3.js scatter plot to MAIDR, generating the accessible data representation. + * + * Extracts x/y data from D3-bound SVG point elements (``, ``, etc.) + * and produces a complete {@link Maidr} data structure. + * + * @param svg - The SVG element containing the D3 scatter plot. + * @param config - Configuration specifying the selector and data accessors. + * @returns A {@link D3BinderResult} with the MAIDR data and generated layer. + * + * @example + * ```ts + * const result = bindD3Scatter(svgElement, { + * selector: 'circle.dot', + * title: 'Height vs Weight', + * axes: { x: 'Height (cm)', y: 'Weight (kg)' }, + * x: 'height', + * y: 'weight', + * }); + * ``` + */ +export function bindD3Scatter(svg: Element, config: D3ScatterConfig): D3BinderResult { + const { + id = generateId(), + title, + subtitle, + caption, + axes, + format, + selector, + x: xAccessor = 'x', + y: yAccessor = 'y', + } = config; + + const elements = queryD3Elements(svg, selector); + if (elements.length === 0) { + throw new Error( + `No elements found for selector "${selector}". ` + + `Ensure the D3 chart has been rendered and the selector matches the point elements.`, + ); + } + + const data: ScatterPoint[] = elements.map(({ datum, index }) => { + if (!datum) { + throw new Error( + `No D3 data bound to element at index ${index}. ` + + `Ensure D3's .data() join has been applied to the "${selector}" elements.`, + ); + } + return { + x: resolveAccessor(datum, xAccessor, index), + y: resolveAccessor(datum, yAccessor, index), + }; + }); + + const layerId = generateId(); + const layer: MaidrLayer = { + id: layerId, + type: TraceType.SCATTER, + title, + selectors: scopeSelector(svg, selector), + axes: buildAxes(axes, format), + data, + }; + + const maidr: Maidr = { + id, + title, + subtitle, + caption, + subplots: [[{ layers: [layer] }]], + }; + + return { maidr, layer }; +} diff --git a/src/d3/bindSegmented.ts b/src/d3/bindSegmented.ts new file mode 100644 index 00000000..f7b1ab28 --- /dev/null +++ b/src/d3/bindSegmented.ts @@ -0,0 +1,166 @@ +/** + * D3 binder for segmented bar charts (stacked, dodged, and normalized). + * + * Extracts data from D3.js-rendered grouped/stacked bar chart SVG elements + * and generates the MAIDR JSON schema for accessible interaction. + */ + +import type { Maidr, MaidrLayer, SegmentedPoint } from '../type/grammar'; +import type { D3BinderResult, D3SegmentedConfig } from './types'; +import { TraceType } from '../type/grammar'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; + +/** + * Binds a D3.js segmented bar chart (stacked, dodged, or normalized) to MAIDR. + * + * Segmented bar charts extend regular bar charts with a `fill` dimension that + * identifies the segment/group within each bar. The data is organized as a + * 2D array where each inner array represents a series/group. + * + * @param svg - The SVG element containing the D3 segmented bar chart. + * @param config - Configuration specifying the selector and data accessors. + * @returns A {@link D3BinderResult} with the MAIDR data and generated layer. + * + * @example + * ```ts + * // Flat structure: each rect has { x, y, fill } data + * const result = bindD3Segmented(svgElement, { + * selector: 'rect.bar', + * type: 'stacked_bar', + * title: 'Revenue by Region and Quarter', + * axes: { x: 'Quarter', y: 'Revenue', fill: 'Region' }, + * x: 'quarter', + * y: 'revenue', + * fill: 'region', + * }); + * + * // d3.stack() structure: groups contain segments + * const result = bindD3Segmented(svgElement, { + * groupSelector: 'g.series', + * selector: 'rect', + * type: 'stacked_bar', + * title: 'Revenue by Region and Quarter', + * x: (d) => d.data.category, + * y: (d) => d[1] - d[0], + * }); + * ``` + */ +export function bindD3Segmented(svg: Element, config: D3SegmentedConfig): D3BinderResult { + const { + id = generateId(), + title, + subtitle, + caption, + axes, + format, + selector, + groupSelector, + type = TraceType.STACKED, + x: xAccessor = 'x', + y: yAccessor = 'y', + fill: fillAccessor = 'fill', + } = config; + + const groupOrder: string[] = []; + const data: SegmentedPoint[][] = []; + + if (groupSelector) { + // d3.stack() pattern: each group contains segment s. + // The group's datum typically has a .key property (d3.stack output). + const groupElements = queryD3Elements(svg, groupSelector); + if (groupElements.length === 0) { + throw new Error( + `No group elements found for selector "${groupSelector}". ` + + `Ensure the D3 chart has been rendered and the selector matches the group elements.`, + ); + } + + for (const { element: groupEl, datum: groupDatum } of groupElements) { + const segments = queryD3Elements(groupEl, selector); + if (segments.length === 0) + continue; + + // Derive fill from group datum's .key (d3.stack) or first segment + const groupKey = (groupDatum as Record | null)?.key as string | undefined; + + const groupPoints: SegmentedPoint[] = segments.map(({ datum, index }) => { + if (!datum) { + throw new Error( + `No D3 data bound to segment element at index ${index} within group. ` + + `Ensure D3's .data() join has been applied to the "${selector}" elements.`, + ); + } + const fillValue = groupKey ?? resolveAccessor(datum, fillAccessor, index); + return { + x: resolveAccessor(datum, xAccessor, index), + y: resolveAccessor(datum, yAccessor, index), + fill: fillValue, + }; + }); + + if (groupPoints.length > 0) { + groupOrder.push(groupPoints[0].fill); + data.push(groupPoints); + } + } + } else { + // Flat structure: all segments in one container, grouped by fill value + const elements = queryD3Elements(svg, selector); + if (elements.length === 0) { + throw new Error( + `No elements found for selector "${selector}". ` + + `Ensure the D3 chart has been rendered and the selector matches the bar elements.`, + ); + } + + const groups = new Map(); + for (const { datum, index } of elements) { + if (!datum) { + throw new Error( + `No D3 data bound to element at index ${index}. ` + + `Ensure D3's .data() join has been applied to the "${selector}" elements.`, + ); + } + const point: SegmentedPoint = { + x: resolveAccessor(datum, xAccessor, index), + y: resolveAccessor(datum, yAccessor, index), + fill: resolveAccessor(datum, fillAccessor, index), + }; + if (!groups.has(point.fill)) { + groupOrder.push(point.fill); + groups.set(point.fill, []); + } + groups.get(point.fill)!.push(point); + } + for (const fill of groupOrder) { + data.push(groups.get(fill)!); + } + } + + const layerId = generateId(); + const selectorValue = groupSelector + ? scopeSelector(svg, `${groupSelector} ${selector}`) + : scopeSelector(svg, selector); + + const layer: MaidrLayer = { + id: layerId, + type, + title, + selectors: selectorValue, + axes: buildAxes(axes, format), + data, + }; + + const maidr: Maidr = { + id, + title, + subtitle, + caption, + subplots: [[{ + legend: groupOrder, + layers: [layer], + }]], + }; + + return { maidr, layer }; +} diff --git a/src/d3/bindSmooth.ts b/src/d3/bindSmooth.ts new file mode 100644 index 00000000..d06ecbad --- /dev/null +++ b/src/d3/bindSmooth.ts @@ -0,0 +1,97 @@ +/** + * D3 binder for smooth/regression curves. + * + * Extracts data from D3.js-rendered smooth curve SVG elements and generates + * the MAIDR JSON schema for accessible smooth plot interaction. + */ + +import type { Maidr, MaidrLayer, SmoothPoint } from '../type/grammar'; +import type { D3BinderResult, D3SmoothConfig } from './types'; +import { TraceType } from '../type/grammar'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; + +/** + * Binds a D3.js smooth/regression curve to MAIDR. + * + * Smooth plots represent fitted curves (e.g., LOESS, regression lines). + * The data includes both the data-space coordinates (x, y) and SVG-space + * coordinates (svg_x, svg_y) for each point along the curve. + * + * @param svg - The SVG element containing the D3 smooth curve. + * @param config - Configuration specifying the selector and data accessors. + * @returns A {@link D3BinderResult} with the MAIDR data and generated layer. + * + * @example + * ```ts + * const result = bindD3Smooth(svgElement, { + * selector: 'circle.smooth-point', + * title: 'LOESS Regression', + * axes: { x: 'X', y: 'Y (predicted)' }, + * x: 'x', + * y: 'yPredicted', + * svgX: 'screenX', + * svgY: 'screenY', + * }); + * ``` + */ +export function bindD3Smooth(svg: Element, config: D3SmoothConfig): D3BinderResult { + const { + id = generateId(), + title, + subtitle, + caption, + axes, + format, + selector, + x: xAccessor = 'x', + y: yAccessor = 'y', + svgX: svgXAccessor = 'svg_x', + svgY: svgYAccessor = 'svg_y', + } = config; + + const elements = queryD3Elements(svg, selector); + if (elements.length === 0) { + throw new Error( + `No elements found for selector "${selector}". ` + + `Ensure the D3 chart has been rendered and the selector matches the smooth curve elements.`, + ); + } + + // Extract points - group into a single series (2D array with one row) + const points: SmoothPoint[] = elements.map(({ datum, index }) => { + if (!datum) { + throw new Error( + `No D3 data bound to element at index ${index}. ` + + `Ensure D3's .data() join has been applied to the "${selector}" elements.`, + ); + } + return { + x: resolveAccessor(datum, xAccessor, index), + y: resolveAccessor(datum, yAccessor, index), + svg_x: resolveAccessor(datum, svgXAccessor, index), + svg_y: resolveAccessor(datum, svgYAccessor, index), + }; + }); + + const data: SmoothPoint[][] = [points]; + + const layerId = generateId(); + const layer: MaidrLayer = { + id: layerId, + type: TraceType.SMOOTH, + title, + selectors: scopeSelector(svg, selector), + axes: buildAxes(axes, format), + data, + }; + + const maidr: Maidr = { + id, + title, + subtitle, + caption, + subplots: [[{ layers: [layer] }]], + }; + + return { maidr, layer }; +} diff --git a/src/d3/index.ts b/src/d3/index.ts new file mode 100644 index 00000000..9eecf7b9 --- /dev/null +++ b/src/d3/index.ts @@ -0,0 +1,113 @@ +/** + * MAIDR D3.js Binder + * + * Provides functions to extract data from D3.js-rendered SVG charts and convert + * them to the MAIDR JSON schema for accessible, non-visual chart interaction. + * + * D3.js is the most widely used low-level SVG-based data visualization library + * on the web. This binder bridges D3 charts to MAIDR's accessibility features + * including audio sonification, text descriptions, braille output, and keyboard + * navigation. + * + * ## Supported Chart Types + * + * - **Bar charts** via {@link bindD3Bar} + * - **Line charts** (single and multi-line) via {@link bindD3Line} + * - **Scatter plots** via {@link bindD3Scatter} + * - **Heatmaps** via {@link bindD3Heatmap} + * - **Box plots** via {@link bindD3Box} + * - **Histograms** via {@link bindD3Histogram} + * - **Candlestick charts** via {@link bindD3Candlestick} + * - **Segmented bar charts** (stacked, dodged, normalized) via {@link bindD3Segmented} + * - **Smooth/regression curves** via {@link bindD3Smooth} + * + * ## How It Works + * + * D3.js binds data to DOM elements via the `__data__` property during `.data()` + * joins. The binder functions query the SVG for chart elements using CSS selectors + * and extract the bound data to generate the MAIDR schema. + * + * ## Usage + * + * ### With Script Tag (vanilla JS) + * ```html + * + * + * ``` + * + * ### With ES Modules + * ```ts + * import { bindD3Bar } from 'maidr/d3'; + * + * const result = bindD3Bar(svgElement, { + * selector: 'rect.bar', + * title: 'My Chart', + * axes: { x: 'Category', y: 'Value' }, + * }); + * ``` + * + * ### With React + * ```tsx + * import { Maidr } from 'maidr/react'; + * import { bindD3Bar } from 'maidr/d3'; + * + * function AccessibleBarChart() { + * const svgRef = useRef(null); + * const [maidrData, setMaidrData] = useState(null); + * + * useEffect(() => { + * // After D3 renders into svgRef.current: + * const result = bindD3Bar(svgRef.current, { ... }); + * setMaidrData(result.maidr); + * }, []); + * + * return maidrData ? ( + * + * ... + * + * ) : ; + * } + * ``` + * + * @packageDocumentation + */ + +// Re-export commonly needed MAIDR types for convenience +export type { Maidr as MaidrData, MaidrLayer, MaidrSubplot } from '../type/grammar'; +export { Orientation, TraceType } from '../type/grammar'; + +// Binder functions +export { bindD3Bar } from './bindBar'; +export { bindD3Box } from './bindBox'; +export { bindD3Candlestick } from './bindCandlestick'; +export { bindD3Heatmap } from './bindHeatmap'; +export { bindD3Histogram } from './bindHistogram'; +export { bindD3Line } from './bindLine'; +export { bindD3Scatter } from './bindScatter'; +export { bindD3Segmented } from './bindSegmented'; +export { bindD3Smooth } from './bindSmooth'; + +// Types +export type { + D3BarConfig, + D3BinderConfig, + D3BinderResult, + D3BoxConfig, + D3CandlestickConfig, + D3HeatmapConfig, + D3HistogramConfig, + D3LineConfig, + D3ScatterConfig, + D3SegmentedConfig, + D3SmoothConfig, + DataAccessor, + SegmentedTraceType, +} from './types'; diff --git a/src/d3/types.ts b/src/d3/types.ts new file mode 100644 index 00000000..76b37aad --- /dev/null +++ b/src/d3/types.ts @@ -0,0 +1,287 @@ +/** + * Configuration types for the MAIDR D3.js binder. + * + * These types define the configuration options for extracting data from + * D3.js-rendered SVG charts and converting them to the MAIDR JSON schema. + */ + +import type { + BarPoint, + BoxPoint, + CandlestickPoint, + FormatConfig, + HeatmapData, + HistogramPoint, + LinePoint, + Maidr, + MaidrLayer, + Orientation, + ScatterPoint, + SegmentedPoint, + SmoothPoint, + TraceType, +} from '../type/grammar'; + +/** + * Common configuration shared across all D3 chart binders. + */ +export interface D3BinderConfig { + /** Unique identifier for the chart. Used as the MAIDR `id`. */ + id?: string; + /** Chart title displayed in text descriptions. */ + title?: string; + /** Chart subtitle. */ + subtitle?: string; + /** Chart caption. */ + caption?: string; + /** Axis labels. */ + axes?: { + x?: string; + y?: string; + fill?: string; + }; + /** Optional formatting configuration for axis values. */ + format?: FormatConfig; +} + +/** + * Data accessor function or property name for extracting a value from a D3 datum. + * If a string is provided, it's used as a property key on the datum object. + * If a function is provided, it receives the datum and its index, returning the value. + */ +export type DataAccessor = string | ((datum: unknown, index: number) => T); + +/** + * Configuration for binding a D3 bar chart. + */ +export interface D3BarConfig extends D3BinderConfig { + /** CSS selector for the bar elements (e.g., `'rect.bar'`, `'rect'`, `'path'`). */ + selector: string; + /** Accessor for the x-axis (category) value. @default 'x' */ + x?: DataAccessor; + /** Accessor for the y-axis (numeric) value. @default 'y' */ + y?: DataAccessor; + /** Chart orientation. @default Orientation.VERTICAL */ + orientation?: Orientation; +} + +/** + * Configuration for binding a D3 line chart. + */ +export interface D3LineConfig extends D3BinderConfig { + /** + * CSS selector for the line path elements (e.g., `'path.line'`, `'.line'`). + * Each matched element represents one line/series. + */ + selector: string; + /** + * CSS selector for the data point elements per line (e.g., `'circle'`). + * If not provided, data is extracted from the line path `__data__` binding. + */ + pointSelector?: string; + /** Accessor for the x-axis value of each point. @default 'x' */ + x?: DataAccessor; + /** Accessor for the y-axis value of each point. @default 'y' */ + y?: DataAccessor; + /** Accessor for the series/fill label. @default 'fill' */ + fill?: DataAccessor; +} + +/** + * Configuration for binding a D3 scatter plot. + */ +export interface D3ScatterConfig extends D3BinderConfig { + /** CSS selector for the point elements (e.g., `'circle'`, `'circle.dot'`). */ + selector: string; + /** Accessor for the x-axis value. @default 'x' */ + x?: DataAccessor; + /** Accessor for the y-axis value. @default 'y' */ + y?: DataAccessor; +} + +/** + * Configuration for binding a D3 heatmap. + */ +export interface D3HeatmapConfig extends D3BinderConfig { + /** CSS selector for the cell elements (e.g., `'rect.cell'`, `'rect'`). */ + selector: string; + /** Accessor for the x-axis category value. @default 'x' */ + x?: DataAccessor; + /** Accessor for the y-axis category value. @default 'y' */ + y?: DataAccessor; + /** Accessor for the cell value. @default 'value' */ + value?: DataAccessor; +} + +/** + * Configuration for binding a D3 box plot. + */ +export interface D3BoxConfig extends D3BinderConfig { + /** + * CSS selector for the box group elements. Each matched element should + * represent one box (e.g., `'g.box'`). + */ + selector: string; + /** Selector for the IQR box rectangle within each box group. @default 'rect' */ + boxSelector?: string; + /** Selector for the median line within each box group. @default 'line.median' */ + medianSelector?: string; + /** Selector for the whisker lines within each box group. */ + whiskerSelector?: string; + /** Selector for outlier points within each box group. @default 'circle' */ + outlierSelector?: string; + /** Accessor for the group/fill label. @default 'fill' */ + fill?: DataAccessor; + /** Accessor for the min value. @default 'min' */ + min?: DataAccessor; + /** Accessor for q1 value. @default 'q1' */ + q1?: DataAccessor; + /** Accessor for median (q2) value. @default 'q2' */ + q2?: DataAccessor; + /** Accessor for q3 value. @default 'q3' */ + q3?: DataAccessor; + /** Accessor for the max value. @default 'max' */ + max?: DataAccessor; + /** Accessor for lower outlier values. @default 'lowerOutliers' */ + lowerOutliers?: DataAccessor; + /** Accessor for upper outlier values. @default 'upperOutliers' */ + upperOutliers?: DataAccessor; + /** Chart orientation. @default Orientation.VERTICAL */ + orientation?: Orientation; +} + +/** + * Configuration for binding a D3 histogram. + */ +export interface D3HistogramConfig extends D3BinderConfig { + /** CSS selector for the histogram bar elements (e.g., `'rect.bar'`, `'rect'`). */ + selector: string; + /** Accessor for the x-axis (bin label) value. @default 'x' */ + x?: DataAccessor; + /** Accessor for the y-axis (count/frequency) value. @default 'y' */ + y?: DataAccessor; + /** Accessor for bin min x value. @default 'x0' */ + xMin?: DataAccessor; + /** Accessor for bin max x value. @default 'x1' */ + xMax?: DataAccessor; + /** Accessor for bin min y value (typically 0). @default 0 */ + yMin?: DataAccessor; + /** Accessor for bin max y value. Defaults to the y accessor. */ + yMax?: DataAccessor; +} + +/** + * Configuration for binding a D3 candlestick chart. + */ +export interface D3CandlestickConfig extends D3BinderConfig { + /** CSS selector for the candlestick body elements (e.g., `'rect.candle'`). */ + selector: string; + /** Accessor for the label/date value. @default 'value' */ + value?: DataAccessor; + /** Accessor for the open price. @default 'open' */ + open?: DataAccessor; + /** Accessor for the high price. @default 'high' */ + high?: DataAccessor; + /** Accessor for the low price. @default 'low' */ + low?: DataAccessor; + /** Accessor for the close price. @default 'close' */ + close?: DataAccessor; + /** Accessor for the trading volume. @default 'volume' */ + volume?: DataAccessor; + /** Accessor for the trend direction. Auto-computed from open/close if not provided. */ + trend?: DataAccessor<'Bull' | 'Bear' | 'Neutral'>; +} + +/** + * Segmented bar chart type for stacked, dodged, or normalized. + */ +export type SegmentedTraceType + = | typeof TraceType.STACKED + | typeof TraceType.DODGED + | typeof TraceType.NORMALIZED; + +/** + * Configuration for binding a D3 segmented bar chart (stacked, dodged, or normalized). + * + * Supports two common D3 patterns: + * + * 1. **Flat structure** (no `groupSelector`): All bar `` elements are queried + * from the SVG root, and each element's datum must include `x`, `y`, and `fill`. + * + * 2. **`d3.stack()` structure** (with `groupSelector`): Each series lives in a + * `` group element whose datum has a `.key` property identifying the series. + * Use function accessors to extract values from the `d3.stack()` tuple format. + * + * @example + * ```ts + * // d3.stack() pattern + * bindD3Segmented(svg, { + * groupSelector: 'g.series', + * selector: 'rect', + * type: 'stacked_bar', + * x: (d) => d.data.category, + * y: (d) => d[1] - d[0], + * }); + * ``` + */ +export interface D3SegmentedConfig extends D3BinderConfig { + /** CSS selector for all bar segment elements (e.g., `'rect.bar'`, `'rect'`). */ + selector: string; + /** + * CSS selector for series group elements (e.g., `'g.series'`). + * When provided, bar segments are queried within each group and the + * fill/series key is read from each group's D3 datum `.key` property + * (standard `d3.stack()` output) unless overridden by the `fill` accessor. + */ + groupSelector?: string; + /** The type of segmented chart. @default TraceType.STACKED */ + type?: SegmentedTraceType; + /** Accessor for the x-axis (category) value. @default 'x' */ + x?: DataAccessor; + /** Accessor for the y-axis (numeric) value. @default 'y' */ + y?: DataAccessor; + /** Accessor for the fill/group identifier. @default 'fill' */ + fill?: DataAccessor; +} + +/** + * Configuration for binding a D3 smooth/regression curve. + */ +export interface D3SmoothConfig extends D3BinderConfig { + /** CSS selector for the smooth curve point elements (e.g., `'circle.smooth'`). */ + selector: string; + /** Accessor for the x-axis data value. @default 'x' */ + x?: DataAccessor; + /** Accessor for the y-axis data value. @default 'y' */ + y?: DataAccessor; + /** Accessor for the SVG x coordinate. @default 'svg_x' */ + svgX?: DataAccessor; + /** Accessor for the SVG y coordinate. @default 'svg_y' */ + svgY?: DataAccessor; +} + +/** + * Result of a D3 binder function. + * Contains the complete MAIDR data structure and the generated layer + * for further customization if needed. + */ +export interface D3BinderResult { + /** Complete MAIDR JSON data ready to use with the `` component or `maidr-data` attribute. */ + maidr: Maidr; + /** The generated layer for direct inspection or modification. */ + layer: MaidrLayer; +} + +/** + * Union of all supported data point types extracted by the D3 binder. + */ +export type D3ExtractedData + = | BarPoint[] + | BoxPoint[] + | CandlestickPoint[] + | HeatmapData + | HistogramPoint[] + | LinePoint[][] + | ScatterPoint[] + | SegmentedPoint[][] + | SmoothPoint[][]; diff --git a/src/d3/util.ts b/src/d3/util.ts new file mode 100644 index 00000000..a9660564 --- /dev/null +++ b/src/d3/util.ts @@ -0,0 +1,225 @@ +/** + * Utility functions for the D3 binder. + * Handles extracting data from D3.js-bound DOM elements. + */ + +import type { FormatConfig } from '../type/grammar'; +import type { D3BinderConfig, DataAccessor } from './types'; + +/** + * Interface for DOM elements with D3's `__data__` property. + * D3.js binds data to elements via this property during `.data()` joins. + */ +interface D3BoundElement extends Element { + __data__?: unknown; +} + +/** + * Escapes a string for use in CSS selectors. + * Uses the native `CSS.escape` when available (browsers), and falls + * back to a basic escape for Node.js / SSR environments. + */ +function cssEscape(value: string): string { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(value); + } + // Fallback: escape characters that are special in CSS identifiers + return value.replace(/([^\w-])/g, '\\$1'); +} + +/** + * Extracts the D3-bound datum from a DOM element. + * D3.js stores bound data on the `__data__` property of DOM elements + * after a `.data()` join. + * + * @param element - The DOM element to extract data from. + * @returns The bound datum, or `undefined` if no data is bound. + */ +export function getD3Datum(element: Element): unknown { + return (element as D3BoundElement).__data__; +} + +/** + * Resolves a {@link DataAccessor} to extract a value from a datum. + * Throws if a string accessor references a property not present on the datum. + * + * @param datum - The data object bound to a D3 element. + * @param accessor - Property key or function to extract the value. + * @param index - The index of the element in its selection. + * @returns The extracted value. + * @throws Error if the string accessor references a missing property. + */ +export function resolveAccessor( + datum: unknown, + accessor: DataAccessor, + index: number, +): T { + if (typeof accessor === 'function') { + return accessor(datum, index); + } + // String accessor: use as property key + const record = datum as Record; + if (!(accessor in record)) { + throw new Error( + `Property "${accessor}" not found on datum at index ${index}. ` + + `Available properties: ${Object.keys(record).join(', ')}`, + ); + } + return record[accessor] as T; +} + +/** + * Attempts to resolve a {@link DataAccessor}, returning `undefined` + * instead of throwing when the property is not found. + * Useful for optional fields like `fill` or outlier arrays. + */ +export function resolveAccessorOptional( + datum: unknown, + accessor: DataAccessor, + index: number, +): T | undefined { + if (typeof accessor === 'function') { + return accessor(datum, index); + } + const record = datum as Record; + if (!(accessor in record)) { + return undefined; + } + return record[accessor] as T; +} + +/** + * Queries all matching elements within a container and returns them with + * their D3-bound data. + * + * @param container - The root element (typically an SVG) to query within. + * @param selector - CSS selector for the target elements. + * @returns Array of `{ element, datum, index }` tuples. + * @throws Error if the selector is empty. + */ +export function queryD3Elements( + container: Element, + selector: string, +): { element: Element; datum: unknown; index: number }[] { + if (!selector) { + throw new Error('CSS selector must not be empty.'); + } + const elements = Array.from(container.querySelectorAll(selector)); + return elements.map((element, index) => ({ + element, + datum: getD3Datum(element), + index, + })); +} + +/** + * Generates a unique CSS selector for a D3 element within its container. + * This creates a selector that MAIDR can use to highlight individual elements. + * + * Strategy: + * 1. Use existing `id` attribute if present. + * 2. Use combination of tag name, classes, and `nth-child` for uniqueness. + * + * @param element - The SVG element to generate a selector for. + * @param container - The root container element. + * @returns A CSS selector string targeting the element. + */ +export function generateSelector( + element: Element, + container: Element, +): string { + if (element.id) { + return `#${cssEscape(element.id)}`; + } + + // Edge case: element IS the container + if (element === container) { + return element.tagName.toLowerCase(); + } + + // Build a selector based on the element's parent chain relative to container + const parts: string[] = []; + let current: Element | null = element; + + while (current && current !== container) { + let part = current.tagName.toLowerCase(); + + if (current.id) { + parts.unshift(`#${cssEscape(current.id)} > ${part}`); + break; + } + + // Add classes if present + const classes = Array.from(current.classList) + .map(c => `.${cssEscape(c)}`) + .join(''); + if (classes) { + part += classes; + } + + // Add nth-child for disambiguation (more reliable than nth-of-type + // in deeply nested SVG structures) + const parent = current.parentElement; + if (parent) { + const childIndex = Array.from(parent.children).indexOf(current) + 1; + part += `:nth-child(${childIndex})`; + } + + parts.unshift(part); + current = current.parentElement; + } + + return parts.join(' > '); +} + +/** + * Generates a single CSS selector that matches all elements in the given + * selector string within the container. This is used for the MAIDR layer + * `selectors` field. + * + * @param container - The root SVG container. + * @param selector - The user-provided CSS selector. + * @returns The selector string, scoped if the container has an ID. + */ +export function scopeSelector(container: Element, selector: string): string { + if (container.id) { + return `#${cssEscape(container.id)} ${selector}`; + } + return selector; +} + +/** + * Generates a unique ID string for use in MAIDR data structures. + * Uses `crypto.randomUUID()` when available, with a fallback for + * environments that lack crypto support. + */ +export function generateId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `d3-${crypto.randomUUID()}`; + } + // Fallback: combine timestamp with multiple random segments + const timestamp = Date.now().toString(36); + const a = Math.random().toString(36).slice(2, 8); + const b = Math.random().toString(36).slice(2, 8); + return `d3-${timestamp}-${a}${b}`; +} + +/** + * Builds the axes configuration for a MAIDR layer, merging axis labels + * with optional format config. + * + * @param axes - Axis label configuration. + * @param format - Optional format configuration. + * @returns The merged axes object, or `undefined` if no axes provided. + */ +export function buildAxes( + axes?: D3BinderConfig['axes'], + format?: FormatConfig, +): Record | undefined { + if (!axes) + return undefined; + return { + ...axes, + ...(format ? { format } : {}), + }; +} diff --git a/vite.d3.config.ts b/vite.d3.config.ts new file mode 100644 index 00000000..ff19ee7e --- /dev/null +++ b/vite.d3.config.ts @@ -0,0 +1,32 @@ +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/d3/index.ts'), + name: 'maidrD3', + formats: ['es', 'umd'], + fileName: format => format === 'es' ? 'd3.mjs' : 'd3.js', + }, + sourcemap: true, + outDir: 'dist', + emptyOutDir: false, + rollupOptions: { + onwarn(warning, warn) { + if (warning.code === 'MODULE_LEVEL_DIRECTIVE') { + return; + } + warn(warning); + }, + }, + }, +});