From 3a443f62416b9c8fe8b69c74411df6254e1e9580 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 20:24:32 +0000 Subject: [PATCH 1/3] feat: add D3.js charting library support via maidr/d3 binder Add a JavaScript binder that extracts data from D3.js-rendered SVG charts and generates the MAIDR JSON schema for accessible chart interaction. Supported chart types: - Bar charts (bindD3Bar) - Line charts, single and multi-line (bindD3Line) - Scatter plots (bindD3Scatter) - Heatmaps (bindD3Heatmap) - Box plots (bindD3Box) - Histograms (bindD3Histogram) The binder leverages D3's __data__ property on DOM elements to extract bound data, and generates CSS selectors for SVG highlighting. Available as both ES module (maidr/d3) and UMD script tag (dist/d3.js). Closes #537 https://claude.ai/code/session_01XrcqYMsCcxfUwhLDpzEwr3 --- examples/d3-bindbar.html | 108 +++++++++++++++++++ examples/d3-bindheatmap.html | 111 +++++++++++++++++++ examples/d3-bindhistogram.html | 108 +++++++++++++++++++ examples/d3-bindline.html | 144 +++++++++++++++++++++++++ examples/d3-bindscatter.html | 112 +++++++++++++++++++ package-lock.json | 4 +- package.json | 8 +- src/d3/bindBar.ts | 96 +++++++++++++++++ src/d3/bindBox.ts | 134 +++++++++++++++++++++++ src/d3/bindHeatmap.ts | 129 ++++++++++++++++++++++ src/d3/bindHistogram.ts | 112 +++++++++++++++++++ src/d3/bindLine.ts | 164 ++++++++++++++++++++++++++++ src/d3/bindScatter.ts | 86 +++++++++++++++ src/d3/index.ts | 103 ++++++++++++++++++ src/d3/types.ts | 190 +++++++++++++++++++++++++++++++++ src/d3/util.ts | 180 +++++++++++++++++++++++++++++++ vite.d3.config.ts | 43 ++++++++ 17 files changed, 1829 insertions(+), 3 deletions(-) create mode 100644 examples/d3-bindbar.html create mode 100644 examples/d3-bindheatmap.html create mode 100644 examples/d3-bindhistogram.html create mode 100644 examples/d3-bindline.html create mode 100644 examples/d3-bindscatter.html create mode 100644 src/d3/bindBar.ts create mode 100644 src/d3/bindBox.ts create mode 100644 src/d3/bindHeatmap.ts create mode 100644 src/d3/bindHistogram.ts create mode 100644 src/d3/bindLine.ts create mode 100644 src/d3/bindScatter.ts create mode 100644 src/d3/index.ts create mode 100644 src/d3/types.ts create mode 100644 src/d3/util.ts create mode 100644 vite.d3.config.ts 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-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/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..b97d693b --- /dev/null +++ b/src/d3/bindBar.ts @@ -0,0 +1,96 @@ +/** + * 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 { 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); + + 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: axes + ? { + ...axes, + ...(format ? { format } : {}), + } + : undefined, + 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..20f9dc11 --- /dev/null +++ b/src/d3/bindBox.ts @@ -0,0 +1,134 @@ +/** + * 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 { generateId, getD3Datum, queryD3Elements, resolveAccessor, 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); + + 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.`, + ); + } + + let lowerOutliers: number[]; + try { + lowerOutliers = resolveAccessor(effectiveDatum, lowerOutliersAccessor, index); + } catch { + lowerOutliers = []; + } + + let upperOutliers: number[]; + try { + upperOutliers = resolveAccessor(effectiveDatum, upperOutliersAccessor, index); + } catch { + upperOutliers = []; + } + + return { + fill: resolveAccessor(effectiveDatum, fillAccessor, index), + lowerOutliers: 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: upperOutliers ?? [], + }; + }); + + const layerId = generateId(); + const layer: MaidrLayer = { + id: layerId, + type: TraceType.BOX, + title, + selectors: scopeSelector(svg, selector), + orientation, + axes: axes + ? { + ...axes, + ...(format ? { format } : {}), + } + : undefined, + 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..a6418014 --- /dev/null +++ b/src/d3/bindHeatmap.ts @@ -0,0 +1,129 @@ +/** + * 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 { 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. + * + * @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); + + // 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 sorted 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 (y rows x columns) + const points: number[][] = []; + const cellMap = new Map(); + for (const cell of cells) { + cellMap.set(`${cell.y}::${cell.x}`, cell.value); + } + + for (const yLabel of yLabels) { + const row: number[] = []; + for (const xLabel of xLabels) { + row.push(cellMap.get(`${yLabel}::${xLabel}`) ?? 0); + } + 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: axes + ? { + ...axes, + ...(format ? { format } : {}), + } + : undefined, + 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..b793f2ed --- /dev/null +++ b/src/d3/bindHistogram.ts @@ -0,0 +1,112 @@ +/** + * 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 { 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); + + 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: axes + ? { + ...axes, + ...(format ? { format } : {}), + } + : undefined, + 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..f66adf61 --- /dev/null +++ b/src/d3/bindLine.ts @@ -0,0 +1,164 @@ +/** + * 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 } from './types'; +import { TraceType } from '../type/grammar'; +import { generateId, queryD3Elements, resolveAccessor, 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`. + * 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); + const data: LinePoint[][] = []; + const selectors: string[] = []; + + if (pointSelector) { + // Extract data from individual point elements grouped by line + // Each line path has associated point elements + for (const { element } of lineElements) { + const parent = element.parentElement ?? svg; + const points = queryD3Elements(parent, pointSelector); + + const lineData: LinePoint[] = points.map(({ datum, index }) => { + 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), + }; + try { + const fill = resolveAccessor(datum, fillAccessor, index); + if (fill !== undefined) { + point.fill = fill; + } + } catch { + // fill accessor not available, skip + } + return point; + }); + + if (lineData.length > 0) { + data.push(lineData); + selectors.push(scopeSelector(svg, `${pointSelector}`)); + } + } + } 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), + }; + try { + const fill = resolveAccessor(d, fillAccessor, index); + if (fill !== undefined) { + point.fill = fill; + } + } catch { + // fill accessor not available, skip + } + return point; + }); + + if (lineData.length > 0) { + data.push(lineData); + } + } + + selectors.push(scopeSelector(svg, selector)); + } + + // 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 layer: MaidrLayer = { + id: layerId, + type: TraceType.LINE, + title, + selectors: selectors.length === 1 ? selectors[0] : selectors, + axes: axes + ? { + ...axes, + ...(format ? { format } : {}), + } + : undefined, + data, + }; + + const maidr: Maidr = { + id, + title, + subtitle, + caption, + subplots: [[{ + ...(legend.length > 0 ? { legend } : {}), + layers: [layer], + }]], + }; + + return { maidr, layer }; +} diff --git a/src/d3/bindScatter.ts b/src/d3/bindScatter.ts new file mode 100644 index 00000000..db65b2b1 --- /dev/null +++ b/src/d3/bindScatter.ts @@ -0,0 +1,86 @@ +/** + * 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 { 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); + + 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: axes + ? { + ...axes, + ...(format ? { format } : {}), + } + : undefined, + 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..d3be2253 --- /dev/null +++ b/src/d3/index.ts @@ -0,0 +1,103 @@ +/** + * 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} + * + * ## 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 { bindD3Heatmap } from './bindHeatmap'; +export { bindD3Histogram } from './bindHistogram'; + +export { bindD3Line } from './bindLine'; + +export { bindD3Scatter } from './bindScatter'; +// Types +export type { + D3BarConfig, + D3BinderConfig, + D3BinderResult, + D3BoxConfig, + D3HeatmapConfig, + D3HistogramConfig, + D3LineConfig, + D3ScatterConfig, + DataAccessor, +} from './types'; diff --git a/src/d3/types.ts b/src/d3/types.ts new file mode 100644 index 00000000..714902c4 --- /dev/null +++ b/src/d3/types.ts @@ -0,0 +1,190 @@ +/** + * 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, + FormatConfig, + HeatmapData, + HistogramPoint, + LinePoint, + Maidr, + MaidrLayer, + Orientation, + ScatterPoint, +} 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; +} + +/** + * 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[] + | HeatmapData + | HistogramPoint[] + | LinePoint[][] + | ScatterPoint[]; diff --git a/src/d3/util.ts b/src/d3/util.ts new file mode 100644 index 00000000..cb39bea8 --- /dev/null +++ b/src/d3/util.ts @@ -0,0 +1,180 @@ +/** + * Utility functions for the D3 binder. + * Handles extracting data from D3.js-bound DOM elements. + */ + +import type { 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; +} + +/** + * 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. + * + * @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. + */ +export function resolveAccessor( + datum: unknown, + accessor: DataAccessor, + index: number, +): T { + if (typeof accessor === 'function') { + return accessor(datum, index); + } + // String accessor: use as property key + return (datum as Record)[accessor]; +} + +/** + * 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. + */ +export function queryD3Elements( + container: Element, + selector: string, +): { element: Element; datum: unknown; index: number }[] { + 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-of-type` 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 `#${CSS.escape(element.id)}`; + } + + // 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(`#${CSS.escape(current.id)} > ${part}`); + break; + } + + // Add classes if present + const classes = Array.from(current.classList) + .map(c => `.${CSS.escape(c)}`) + .join(''); + if (classes) { + part += classes; + } + + // Add nth-of-type for disambiguation + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter( + s => s.tagName === current!.tagName, + ); + if (siblings.length > 1) { + const idx = siblings.indexOf(current) + 1; + part += `:nth-of-type(${idx})`; + } + } + + 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 `#${CSS.escape(container.id)} ${selector}`; + } + return selector; +} + +/** + * Generates a unique ID string for use in MAIDR data structures. + */ +export function generateId(): string { + return `d3-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Extracts data from D3-bound elements using the provided accessors. + * Falls back to DOM attribute extraction if `__data__` is not available. + */ +export function extractBarDataFromDOM( + elements: Element[], + orientation: 'vert' | 'horz', +): { x: string | number; y: number }[] { + return elements.map((el) => { + const datum = getD3Datum(el); + if (datum && typeof datum === 'object') { + return datum as { x: string | number; y: number }; + } + + // Fallback: try to infer from SVG attributes (rect elements) + const tag = el.tagName.toLowerCase(); + if (tag === 'rect') { + const x = Number(el.getAttribute('x') ?? 0); + const y = Number(el.getAttribute('y') ?? 0); + const width = Number(el.getAttribute('width') ?? 0); + const height = Number(el.getAttribute('height') ?? 0); + + if (orientation === 'vert') { + return { x: x + width / 2, y: height }; + } + return { x: width, y: y + height / 2 }; + } + + return { x: 0, y: 0 }; + }); +} diff --git a/vite.d3.config.ts b/vite.d3.config.ts new file mode 100644 index 00000000..5d1b9b69 --- /dev/null +++ b/vite.d3.config.ts @@ -0,0 +1,43 @@ +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); + }, + }, + }, + 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'), + }, + }, +}); From d569ec80138002467c757e29ae94b89eb8da75f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 02:25:54 +0000 Subject: [PATCH 2/3] fix: address PR review issues and add missing chart type binders - Fix resolveAccessor to throw on missing properties instead of returning undefined - Add resolveAccessorOptional for optional fields (outliers, fill, volume) - Remove extractBarDataFromDOM fallback that used pixel values as data - Fix bindLine pointSelector scoping bug with element deduplication Set - Fix heatmap: use nested Maps instead of composite string keys, throw on missing cells - Fix bindBox: use resolveAccessorOptional for outliers instead of try-catch - Standardize selectors format to string across all binders - Remove unused path aliases from vite.d3.config.ts - Add bindD3Candlestick for candlestick/OHLC charts - Add bindD3Segmented for stacked, dodged, and normalized bar charts - Add bindD3Smooth for smooth/regression curves - Add example HTML files for all new chart types https://claude.ai/code/session_01XrcqYMsCcxfUwhLDpzEwr3 --- examples/d3-bindbox.html | 83 +++++++++++++++++++++ examples/d3-bindcandlestick.html | 81 ++++++++++++++++++++ examples/d3-binddodged.html | 76 +++++++++++++++++++ examples/d3-bindsmooth.html | 84 +++++++++++++++++++++ examples/d3-bindstacked.html | 100 +++++++++++++++++++++++++ src/d3/bindBox.ts | 22 ++---- src/d3/bindCandlestick.ts | 122 +++++++++++++++++++++++++++++++ src/d3/bindHeatmap.ts | 27 +++++-- src/d3/bindLine.ts | 48 ++++++------ src/d3/bindScatter.ts | 2 +- src/d3/bindSegmented.ts | 118 ++++++++++++++++++++++++++++++ src/d3/bindSmooth.ts | 96 ++++++++++++++++++++++++ src/d3/index.ts | 14 +++- src/d3/types.ts | 71 +++++++++++++++++- src/d3/util.ts | 77 +++++++++---------- vite.d3.config.ts | 11 --- 16 files changed, 928 insertions(+), 104 deletions(-) create mode 100644 examples/d3-bindbox.html create mode 100644 examples/d3-bindcandlestick.html create mode 100644 examples/d3-binddodged.html create mode 100644 examples/d3-bindsmooth.html create mode 100644 examples/d3-bindstacked.html create mode 100644 src/d3/bindCandlestick.ts create mode 100644 src/d3/bindSegmented.ts create mode 100644 src/d3/bindSmooth.ts 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-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/src/d3/bindBox.ts b/src/d3/bindBox.ts index 20f9dc11..f6c18565 100644 --- a/src/d3/bindBox.ts +++ b/src/d3/bindBox.ts @@ -8,7 +8,7 @@ import type { BoxPoint, Maidr, MaidrLayer } from '../type/grammar'; import type { D3BinderResult, D3BoxConfig } from './types'; import { Orientation, TraceType } from '../type/grammar'; -import { generateId, getD3Datum, queryD3Elements, resolveAccessor, scopeSelector } from './util'; +import { generateId, getD3Datum, queryD3Elements, resolveAccessor, resolveAccessorOptional, scopeSelector } from './util'; /** * Binds a D3.js box plot to MAIDR, generating the accessible data representation. @@ -80,29 +80,19 @@ export function bindD3Box(svg: Element, config: D3BoxConfig): D3BinderResult { ); } - let lowerOutliers: number[]; - try { - lowerOutliers = resolveAccessor(effectiveDatum, lowerOutliersAccessor, index); - } catch { - lowerOutliers = []; - } - - let upperOutliers: number[]; - try { - upperOutliers = resolveAccessor(effectiveDatum, upperOutliersAccessor, index); - } catch { - upperOutliers = []; - } + // 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: lowerOutliers ?? [], + 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: upperOutliers ?? [], + upperOutliers, }; }); diff --git a/src/d3/bindCandlestick.ts b/src/d3/bindCandlestick.ts new file mode 100644 index 00000000..44f056e8 --- /dev/null +++ b/src/d3/bindCandlestick.ts @@ -0,0 +1,122 @@ +/** + * 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 { 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); + + 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: axes + ? { + ...axes, + ...(format ? { format } : {}), + } + : undefined, + 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 index a6418014..ae8243fc 100644 --- a/src/d3/bindHeatmap.ts +++ b/src/d3/bindHeatmap.ts @@ -20,6 +20,7 @@ import { generateId, queryD3Elements, resolveAccessor, scopeSelector } from './u * @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 @@ -64,7 +65,7 @@ export function bindD3Heatmap(svg: Element, config: D3HeatmapConfig): D3BinderRe }; }); - // Build unique sorted x and y labels (preserving order of appearance) + // Build unique x and y labels (preserving order of appearance) const xLabels: string[] = []; const yLabels: string[] = []; const seenX = new Set(); @@ -81,17 +82,31 @@ export function bindD3Heatmap(svg: Element, config: D3HeatmapConfig): D3BinderRe } } - // Build the 2D points grid (y rows x columns) - const points: number[][] = []; - const cellMap = new Map(); + // Build the 2D points grid using nested Maps to avoid key collisions + const cellMap = new Map>(); for (const cell of cells) { - cellMap.set(`${cell.y}::${cell.x}`, cell.value); + 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) { - row.push(cellMap.get(`${yLabel}::${xLabel}`) ?? 0); + 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); } diff --git a/src/d3/bindLine.ts b/src/d3/bindLine.ts index f66adf61..c8f9171b 100644 --- a/src/d3/bindLine.ts +++ b/src/d3/bindLine.ts @@ -8,13 +8,15 @@ import type { LinePoint, Maidr, MaidrLayer } from '../type/grammar'; import type { D3BinderResult, D3LineConfig } from './types'; import { TraceType } from '../type/grammar'; -import { generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; +import { 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. @@ -52,16 +54,22 @@ export function bindD3Line(svg: Element, config: D3LineConfig): D3BinderResult { const lineElements = queryD3Elements(svg, selector); const data: LinePoint[][] = []; - const selectors: string[] = []; if (pointSelector) { - // Extract data from individual point elements grouped by line - // Each line path has associated point elements + // Track which point elements we've already processed to avoid + // double-counting when multiple line paths share a parent . + const processedPoints = new Set(); + for (const { element } of lineElements) { const parent = element.parentElement ?? svg; const points = queryD3Elements(parent, pointSelector); - const lineData: LinePoint[] = points.map(({ datum, index }) => { + const lineData: LinePoint[] = []; + for (const { element: pointEl, datum, index } of points) { + if (processedPoints.has(pointEl)) + continue; + processedPoints.add(pointEl); + if (!datum) { throw new Error( `No D3 data bound to point element at index ${index}. ` @@ -72,20 +80,15 @@ export function bindD3Line(svg: Element, config: D3LineConfig): D3BinderResult { x: resolveAccessor(datum, xAccessor, index), y: resolveAccessor(datum, yAccessor, index), }; - try { - const fill = resolveAccessor(datum, fillAccessor, index); - if (fill !== undefined) { - point.fill = fill; - } - } catch { - // fill accessor not available, skip + const fill = resolveAccessorOptional(datum, fillAccessor, index); + if (fill !== undefined) { + point.fill = fill; } - return point; - }); + lineData.push(point); + } if (lineData.length > 0) { data.push(lineData); - selectors.push(scopeSelector(svg, `${pointSelector}`)); } } } else { @@ -106,13 +109,9 @@ export function bindD3Line(svg: Element, config: D3LineConfig): D3BinderResult { x: resolveAccessor(d, xAccessor, index), y: resolveAccessor(d, yAccessor, index), }; - try { - const fill = resolveAccessor(d, fillAccessor, index); - if (fill !== undefined) { - point.fill = fill; - } - } catch { - // fill accessor not available, skip + const fill = resolveAccessorOptional(d, fillAccessor, index); + if (fill !== undefined) { + point.fill = fill; } return point; }); @@ -121,8 +120,6 @@ export function bindD3Line(svg: Element, config: D3LineConfig): D3BinderResult { data.push(lineData); } } - - selectors.push(scopeSelector(svg, selector)); } // Extract legend labels from fill values @@ -135,11 +132,12 @@ export function bindD3Line(svg: Element, config: D3LineConfig): D3BinderResult { } const layerId = generateId(); + const selectorValue = scopeSelector(svg, selector); const layer: MaidrLayer = { id: layerId, type: TraceType.LINE, title, - selectors: selectors.length === 1 ? selectors[0] : selectors, + selectors: selectorValue, axes: axes ? { ...axes, diff --git a/src/d3/bindScatter.ts b/src/d3/bindScatter.ts index db65b2b1..df883475 100644 --- a/src/d3/bindScatter.ts +++ b/src/d3/bindScatter.ts @@ -64,7 +64,7 @@ export function bindD3Scatter(svg: Element, config: D3ScatterConfig): D3BinderRe id: layerId, type: TraceType.SCATTER, title, - selectors: [scopeSelector(svg, selector)], + selectors: scopeSelector(svg, selector), axes: axes ? { ...axes, diff --git a/src/d3/bindSegmented.ts b/src/d3/bindSegmented.ts new file mode 100644 index 00000000..d371ea0c --- /dev/null +++ b/src/d3/bindSegmented.ts @@ -0,0 +1,118 @@ +/** + * 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 { 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 + * // Stacked bar chart + * 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', + * }); + * + * // Dodged bar chart + * const result = bindD3Segmented(svgElement, { + * selector: 'rect.bar', + * type: 'dodged_bar', + * title: 'Comparison by Category', + * axes: { x: 'Category', y: 'Value', fill: 'Group' }, + * }); + * ``` + */ +export function bindD3Segmented(svg: Element, config: D3SegmentedConfig): D3BinderResult { + const { + id = generateId(), + title, + subtitle, + caption, + axes, + format, + selector, + type = TraceType.STACKED, + x: xAccessor = 'x', + y: yAccessor = 'y', + fill: fillAccessor = 'fill', + } = config; + + const elements = queryD3Elements(svg, selector); + + // Extract flat list of segmented points + const flatPoints: SegmentedPoint[] = 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), + fill: resolveAccessor(datum, fillAccessor, index), + }; + }); + + // Group by fill value to form 2D array (each group = one series) + const groupOrder: string[] = []; + const groups = new Map(); + for (const point of flatPoints) { + if (!groups.has(point.fill)) { + groupOrder.push(point.fill); + groups.set(point.fill, []); + } + groups.get(point.fill)!.push(point); + } + + const data: SegmentedPoint[][] = groupOrder.map(fill => groups.get(fill)!); + + const layerId = generateId(); + const layer: MaidrLayer = { + id: layerId, + type, + title, + selectors: scopeSelector(svg, selector), + axes: axes + ? { + ...axes, + ...(format ? { format } : {}), + } + : undefined, + 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..d956e022 --- /dev/null +++ b/src/d3/bindSmooth.ts @@ -0,0 +1,96 @@ +/** + * 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 { 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); + + // 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: axes + ? { + ...axes, + ...(format ? { format } : {}), + } + : undefined, + 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 index d3be2253..9eecf7b9 100644 --- a/src/d3/index.ts +++ b/src/d3/index.ts @@ -17,6 +17,9 @@ * - **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 * @@ -80,24 +83,31 @@ // 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 index 714902c4..4202cad9 100644 --- a/src/d3/types.ts +++ b/src/d3/types.ts @@ -8,6 +8,7 @@ import type { BarPoint, BoxPoint, + CandlestickPoint, FormatConfig, HeatmapData, HistogramPoint, @@ -16,6 +17,9 @@ import type { MaidrLayer, Orientation, ScatterPoint, + SegmentedPoint, + SmoothPoint, + TraceType, } from '../type/grammar'; /** @@ -166,6 +170,68 @@ export interface D3HistogramConfig extends D3BinderConfig { 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). + */ +export interface D3SegmentedConfig extends D3BinderConfig { + /** CSS selector for all bar segment elements (e.g., `'rect.bar'`). */ + selector: 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 @@ -184,7 +250,10 @@ export interface D3BinderResult { export type D3ExtractedData = | BarPoint[] | BoxPoint[] + | CandlestickPoint[] | HeatmapData | HistogramPoint[] | LinePoint[][] - | ScatterPoint[]; + | ScatterPoint[] + | SegmentedPoint[][] + | SmoothPoint[][]; diff --git a/src/d3/util.ts b/src/d3/util.ts index cb39bea8..7505d0f6 100644 --- a/src/d3/util.ts +++ b/src/d3/util.ts @@ -27,11 +27,13 @@ export function getD3Datum(element: Element): unknown { /** * 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, @@ -42,7 +44,34 @@ export function resolveAccessor( return accessor(datum, index); } // String accessor: use as property key - return (datum as Record)[accessor]; + 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; } /** @@ -71,7 +100,7 @@ export function queryD3Elements( * * Strategy: * 1. Use existing `id` attribute if present. - * 2. Use combination of tag name, classes, and `nth-of-type` for uniqueness. + * 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. @@ -105,16 +134,12 @@ export function generateSelector( part += classes; } - // Add nth-of-type for disambiguation + // Add nth-child for disambiguation (more reliable than nth-of-type + // in deeply nested SVG structures) const parent = current.parentElement; if (parent) { - const siblings = Array.from(parent.children).filter( - s => s.tagName === current!.tagName, - ); - if (siblings.length > 1) { - const idx = siblings.indexOf(current) + 1; - part += `:nth-of-type(${idx})`; - } + const childIndex = Array.from(parent.children).indexOf(current) + 1; + part += `:nth-child(${childIndex})`; } parts.unshift(part); @@ -146,35 +171,3 @@ export function scopeSelector(container: Element, selector: string): string { export function generateId(): string { return `d3-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } - -/** - * Extracts data from D3-bound elements using the provided accessors. - * Falls back to DOM attribute extraction if `__data__` is not available. - */ -export function extractBarDataFromDOM( - elements: Element[], - orientation: 'vert' | 'horz', -): { x: string | number; y: number }[] { - return elements.map((el) => { - const datum = getD3Datum(el); - if (datum && typeof datum === 'object') { - return datum as { x: string | number; y: number }; - } - - // Fallback: try to infer from SVG attributes (rect elements) - const tag = el.tagName.toLowerCase(); - if (tag === 'rect') { - const x = Number(el.getAttribute('x') ?? 0); - const y = Number(el.getAttribute('y') ?? 0); - const width = Number(el.getAttribute('width') ?? 0); - const height = Number(el.getAttribute('height') ?? 0); - - if (orientation === 'vert') { - return { x: x + width / 2, y: height }; - } - return { x: width, y: y + height / 2 }; - } - - return { x: 0, y: 0 }; - }); -} diff --git a/vite.d3.config.ts b/vite.d3.config.ts index 5d1b9b69..ff19ee7e 100644 --- a/vite.d3.config.ts +++ b/vite.d3.config.ts @@ -29,15 +29,4 @@ export default defineConfig({ }, }, }, - 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'), - }, - }, }); From 97a024b7a738ee1341336773705ccec5dc5aa1ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 22:19:38 +0000 Subject: [PATCH 3/3] fix: address second round of PR review feedback - Fix multi-line extraction: replace processedPoints Set with parent-based grouping (Pattern A: distinct parents) and fill-based grouping (Pattern B: shared parent) to correctly extract all lines - Fix generateSelector edge case: return tag name when element equals container instead of empty string - Add groupSelector option to bindD3Segmented for d3.stack() layouts where series live in groups with .key datum property - Add empty selector guard: all binders now throw descriptive errors when CSS selector matches zero elements - Fix ID collision: use crypto.randomUUID() when available, with improved fallback using multiple random segments - Fix CSS.escape SSR compatibility: add cssEscape() wrapper that falls back to regex-based escaping in Node.js environments - Extract buildAxes() helper to eliminate duplicated axes construction pattern across all nine binders https://claude.ai/code/session_01XrcqYMsCcxfUwhLDpzEwr3 --- src/d3/bindBar.ts | 15 ++--- src/d3/bindBox.ts | 15 ++--- src/d3/bindCandlestick.ts | 15 ++--- src/d3/bindHeatmap.ts | 15 ++--- src/d3/bindHistogram.ts | 15 ++--- src/d3/bindLine.ts | 105 ++++++++++++++++++++++++++------- src/d3/bindScatter.ts | 15 ++--- src/d3/bindSegmented.ts | 120 ++++++++++++++++++++++++++------------ src/d3/bindSmooth.ts | 15 ++--- src/d3/types.ts | 30 +++++++++- src/d3/util.ts | 64 ++++++++++++++++++-- 11 files changed, 310 insertions(+), 114 deletions(-) diff --git a/src/d3/bindBar.ts b/src/d3/bindBar.ts index b97d693b..afaf737a 100644 --- a/src/d3/bindBar.ts +++ b/src/d3/bindBar.ts @@ -8,7 +8,7 @@ import type { BarPoint, Maidr, MaidrLayer } from '../type/grammar'; import type { D3BarConfig, D3BinderResult } from './types'; import { Orientation, TraceType } from '../type/grammar'; -import { generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; /** * Binds a D3.js bar chart to MAIDR, generating the accessible data representation. @@ -54,6 +54,12 @@ export function bindD3Bar(svg: Element, config: D3BarConfig): D3BinderResult { } = 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) { @@ -75,12 +81,7 @@ export function bindD3Bar(svg: Element, config: D3BarConfig): D3BinderResult { title, selectors: scopeSelector(svg, selector), orientation, - axes: axes - ? { - ...axes, - ...(format ? { format } : {}), - } - : undefined, + axes: buildAxes(axes, format), data, }; diff --git a/src/d3/bindBox.ts b/src/d3/bindBox.ts index f6c18565..541cda01 100644 --- a/src/d3/bindBox.ts +++ b/src/d3/bindBox.ts @@ -8,7 +8,7 @@ import type { BoxPoint, Maidr, MaidrLayer } from '../type/grammar'; import type { D3BinderResult, D3BoxConfig } from './types'; import { Orientation, TraceType } from '../type/grammar'; -import { generateId, getD3Datum, queryD3Elements, resolveAccessor, resolveAccessorOptional, scopeSelector } from './util'; +import { buildAxes, generateId, getD3Datum, queryD3Elements, resolveAccessor, resolveAccessorOptional, scopeSelector } from './util'; /** * Binds a D3.js box plot to MAIDR, generating the accessible data representation. @@ -60,6 +60,12 @@ export function bindD3Box(svg: Element, config: D3BoxConfig): D3BinderResult { } = 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 @@ -103,12 +109,7 @@ export function bindD3Box(svg: Element, config: D3BoxConfig): D3BinderResult { title, selectors: scopeSelector(svg, selector), orientation, - axes: axes - ? { - ...axes, - ...(format ? { format } : {}), - } - : undefined, + axes: buildAxes(axes, format), data, }; diff --git a/src/d3/bindCandlestick.ts b/src/d3/bindCandlestick.ts index 44f056e8..7d161219 100644 --- a/src/d3/bindCandlestick.ts +++ b/src/d3/bindCandlestick.ts @@ -8,7 +8,7 @@ import type { CandlestickPoint, CandlestickTrend, Maidr, MaidrLayer } from '../type/grammar'; import type { D3BinderResult, D3CandlestickConfig } from './types'; import { TraceType } from '../type/grammar'; -import { generateId, queryD3Elements, resolveAccessor, resolveAccessorOptional, scopeSelector } from './util'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, resolveAccessorOptional, scopeSelector } from './util'; /** * Binds a D3.js candlestick chart to MAIDR. @@ -55,6 +55,12 @@ export function bindD3Candlestick(svg: Element, config: D3CandlestickConfig): D3 } = 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) { @@ -101,12 +107,7 @@ export function bindD3Candlestick(svg: Element, config: D3CandlestickConfig): D3 type: TraceType.CANDLESTICK, title, selectors: scopeSelector(svg, selector), - axes: axes - ? { - ...axes, - ...(format ? { format } : {}), - } - : undefined, + axes: buildAxes(axes, format), data, }; diff --git a/src/d3/bindHeatmap.ts b/src/d3/bindHeatmap.ts index ae8243fc..ab2464ee 100644 --- a/src/d3/bindHeatmap.ts +++ b/src/d3/bindHeatmap.ts @@ -8,7 +8,7 @@ import type { HeatmapData, Maidr, MaidrLayer } from '../type/grammar'; import type { D3BinderResult, D3HeatmapConfig } from './types'; import { TraceType } from '../type/grammar'; -import { generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; /** * Binds a D3.js heatmap to MAIDR, generating the accessible data representation. @@ -49,6 +49,12 @@ export function bindD3Heatmap(svg: Element, config: D3HeatmapConfig): D3BinderRe } = 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 }) => { @@ -123,12 +129,7 @@ export function bindD3Heatmap(svg: Element, config: D3HeatmapConfig): D3BinderRe type: TraceType.HEATMAP, title, selectors: scopeSelector(svg, selector), - axes: axes - ? { - ...axes, - ...(format ? { format } : {}), - } - : undefined, + axes: buildAxes(axes, format), data, }; diff --git a/src/d3/bindHistogram.ts b/src/d3/bindHistogram.ts index b793f2ed..c00685e3 100644 --- a/src/d3/bindHistogram.ts +++ b/src/d3/bindHistogram.ts @@ -8,7 +8,7 @@ import type { HistogramPoint, Maidr, MaidrLayer } from '../type/grammar'; import type { D3BinderResult, D3HistogramConfig } from './types'; import { TraceType } from '../type/grammar'; -import { generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; /** * Binds a D3.js histogram to MAIDR, generating the accessible data representation. @@ -55,6 +55,12 @@ export function bindD3Histogram(svg: Element, config: D3HistogramConfig): D3Bind } = 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) { @@ -91,12 +97,7 @@ export function bindD3Histogram(svg: Element, config: D3HistogramConfig): D3Bind type: TraceType.HISTOGRAM, title, selectors: scopeSelector(svg, selector), - axes: axes - ? { - ...axes, - ...(format ? { format } : {}), - } - : undefined, + axes: buildAxes(axes, format), data, }; diff --git a/src/d3/bindLine.ts b/src/d3/bindLine.ts index c8f9171b..38952c6b 100644 --- a/src/d3/bindLine.ts +++ b/src/d3/bindLine.ts @@ -6,9 +6,9 @@ */ import type { LinePoint, Maidr, MaidrLayer } from '../type/grammar'; -import type { D3BinderResult, D3LineConfig } from './types'; +import type { D3BinderResult, D3LineConfig, DataAccessor } from './types'; import { TraceType } from '../type/grammar'; -import { generateId, queryD3Elements, resolveAccessor, resolveAccessorOptional, scopeSelector } from './util'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, resolveAccessorOptional, scopeSelector } from './util'; /** * Binds a D3.js line chart to MAIDR, generating the accessible data representation. @@ -53,23 +53,52 @@ export function bindD3Line(svg: Element, config: D3LineConfig): D3BinderResult { } = 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) { - // Track which point elements we've already processed to avoid - // double-counting when multiple line paths share a parent . - const processedPoints = new Set(); + // 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), + ); - for (const { element } of lineElements) { - const parent = element.parentElement ?? svg; - const points = queryD3Elements(parent, pointSelector); + 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 lineData: LinePoint[] = []; - for (const { element: pointEl, datum, index } of points) { - if (processedPoints.has(pointEl)) - continue; - processedPoints.add(pointEl); + 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}. ` @@ -84,11 +113,17 @@ export function bindD3Line(svg: Element, config: D3LineConfig): D3BinderResult { if (fill !== undefined) { point.fill = fill; } - lineData.push(point); + + const key = fill ?? '__default__'; + if (!lineMap.has(key)) { + lineOrder.push(key); + lineMap.set(key, []); + } + lineMap.get(key)!.push(point); } - if (lineData.length > 0) { - data.push(lineData); + for (const key of lineOrder) { + data.push(lineMap.get(key)!); } } } else { @@ -138,12 +173,7 @@ export function bindD3Line(svg: Element, config: D3LineConfig): D3BinderResult { type: TraceType.LINE, title, selectors: selectorValue, - axes: axes - ? { - ...axes, - ...(format ? { format } : {}), - } - : undefined, + axes: buildAxes(axes, format), data, }; @@ -160,3 +190,34 @@ export function bindD3Line(svg: Element, config: D3LineConfig): D3BinderResult { 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 index df883475..cca34f5c 100644 --- a/src/d3/bindScatter.ts +++ b/src/d3/bindScatter.ts @@ -8,7 +8,7 @@ import type { Maidr, MaidrLayer, ScatterPoint } from '../type/grammar'; import type { D3BinderResult, D3ScatterConfig } from './types'; import { TraceType } from '../type/grammar'; -import { generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; /** * Binds a D3.js scatter plot to MAIDR, generating the accessible data representation. @@ -45,6 +45,12 @@ export function bindD3Scatter(svg: Element, config: D3ScatterConfig): D3BinderRe } = 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) { @@ -65,12 +71,7 @@ export function bindD3Scatter(svg: Element, config: D3ScatterConfig): D3BinderRe type: TraceType.SCATTER, title, selectors: scopeSelector(svg, selector), - axes: axes - ? { - ...axes, - ...(format ? { format } : {}), - } - : undefined, + axes: buildAxes(axes, format), data, }; diff --git a/src/d3/bindSegmented.ts b/src/d3/bindSegmented.ts index d371ea0c..f7b1ab28 100644 --- a/src/d3/bindSegmented.ts +++ b/src/d3/bindSegmented.ts @@ -8,7 +8,7 @@ import type { Maidr, MaidrLayer, SegmentedPoint } from '../type/grammar'; import type { D3BinderResult, D3SegmentedConfig } from './types'; import { TraceType } from '../type/grammar'; -import { generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; /** * Binds a D3.js segmented bar chart (stacked, dodged, or normalized) to MAIDR. @@ -23,7 +23,7 @@ import { generateId, queryD3Elements, resolveAccessor, scopeSelector } from './u * * @example * ```ts - * // Stacked bar chart + * // Flat structure: each rect has { x, y, fill } data * const result = bindD3Segmented(svgElement, { * selector: 'rect.bar', * type: 'stacked_bar', @@ -34,12 +34,14 @@ import { generateId, queryD3Elements, resolveAccessor, scopeSelector } from './u * fill: 'region', * }); * - * // Dodged bar chart + * // d3.stack() structure: groups contain segments * const result = bindD3Segmented(svgElement, { - * selector: 'rect.bar', - * type: 'dodged_bar', - * title: 'Comparison by Category', - * axes: { x: 'Category', y: 'Value', fill: 'Group' }, + * 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], * }); * ``` */ @@ -52,54 +54,100 @@ export function bindD3Segmented(svg: Element, config: D3SegmentedConfig): D3Bind axes, format, selector, + groupSelector, type = TraceType.STACKED, x: xAccessor = 'x', y: yAccessor = 'y', fill: fillAccessor = 'fill', } = config; - const elements = queryD3Elements(svg, selector); + const groupOrder: string[] = []; + const data: SegmentedPoint[][] = []; - // Extract flat list of segmented points - const flatPoints: SegmentedPoint[] = elements.map(({ datum, index }) => { - if (!datum) { + 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 D3 data bound to element at index ${index}. ` - + `Ensure D3's .data() join has been applied to the "${selector}" elements.`, + `No group elements found for selector "${groupSelector}". ` + + `Ensure the D3 chart has been rendered and the selector matches the group elements.`, ); } - return { - x: resolveAccessor(datum, xAccessor, index), - y: resolveAccessor(datum, yAccessor, index), - fill: resolveAccessor(datum, fillAccessor, index), - }; - }); - // Group by fill value to form 2D array (each group = one series) - const groupOrder: string[] = []; - const groups = new Map(); - for (const point of flatPoints) { - if (!groups.has(point.fill)) { - groupOrder.push(point.fill); - groups.set(point.fill, []); + 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.`, + ); } - groups.get(point.fill)!.push(point); - } - const data: SegmentedPoint[][] = groupOrder.map(fill => groups.get(fill)!); + 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: scopeSelector(svg, selector), - axes: axes - ? { - ...axes, - ...(format ? { format } : {}), - } - : undefined, + selectors: selectorValue, + axes: buildAxes(axes, format), data, }; diff --git a/src/d3/bindSmooth.ts b/src/d3/bindSmooth.ts index d956e022..d06ecbad 100644 --- a/src/d3/bindSmooth.ts +++ b/src/d3/bindSmooth.ts @@ -8,7 +8,7 @@ import type { Maidr, MaidrLayer, SmoothPoint } from '../type/grammar'; import type { D3BinderResult, D3SmoothConfig } from './types'; import { TraceType } from '../type/grammar'; -import { generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; +import { buildAxes, generateId, queryD3Elements, resolveAccessor, scopeSelector } from './util'; /** * Binds a D3.js smooth/regression curve to MAIDR. @@ -50,6 +50,12 @@ export function bindD3Smooth(svg: Element, config: D3SmoothConfig): D3BinderResu } = 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 }) => { @@ -75,12 +81,7 @@ export function bindD3Smooth(svg: Element, config: D3SmoothConfig): D3BinderResu type: TraceType.SMOOTH, title, selectors: scopeSelector(svg, selector), - axes: axes - ? { - ...axes, - ...(format ? { format } : {}), - } - : undefined, + axes: buildAxes(axes, format), data, }; diff --git a/src/d3/types.ts b/src/d3/types.ts index 4202cad9..76b37aad 100644 --- a/src/d3/types.ts +++ b/src/d3/types.ts @@ -202,10 +202,38 @@ export type SegmentedTraceType /** * 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'`). */ + /** 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' */ diff --git a/src/d3/util.ts b/src/d3/util.ts index 7505d0f6..a9660564 100644 --- a/src/d3/util.ts +++ b/src/d3/util.ts @@ -3,7 +3,8 @@ * Handles extracting data from D3.js-bound DOM elements. */ -import type { DataAccessor } from './types'; +import type { FormatConfig } from '../type/grammar'; +import type { D3BinderConfig, DataAccessor } from './types'; /** * Interface for DOM elements with D3's `__data__` property. @@ -13,6 +14,19 @@ 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 @@ -81,11 +95,15 @@ export function resolveAccessorOptional( * @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, @@ -111,7 +129,12 @@ export function generateSelector( container: Element, ): string { if (element.id) { - return `#${CSS.escape(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 @@ -122,13 +145,13 @@ export function generateSelector( let part = current.tagName.toLowerCase(); if (current.id) { - parts.unshift(`#${CSS.escape(current.id)} > ${part}`); + parts.unshift(`#${cssEscape(current.id)} > ${part}`); break; } // Add classes if present const classes = Array.from(current.classList) - .map(c => `.${CSS.escape(c)}`) + .map(c => `.${cssEscape(c)}`) .join(''); if (classes) { part += classes; @@ -160,14 +183,43 @@ export function generateSelector( */ export function scopeSelector(container: Element, selector: string): string { if (container.id) { - return `#${CSS.escape(container.id)} ${selector}`; + 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 { - return `d3-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + 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 } : {}), + }; }