From 2387bbe69cff0eaf7a525425e0446ed5c34010a4 Mon Sep 17 00:00:00 2001 From: Kumar Kshitij Date: Tue, 11 Nov 2025 00:04:48 +0530 Subject: [PATCH 1/3] enable multiplot image export --- .../AnnotationOnlyChart.tsx | 12 +- .../components/AreaChart/AreaChart.base.tsx | 9 +- .../components/ChartTable/ChartTable.base.tsx | 4 +- .../DeclarativeChart/DeclarativeChart.tsx | 78 ++++-- .../components/DonutChart/DonutChart.base.tsx | 9 +- .../FunnelChart/FunnelChart.base.tsx | 11 +- .../components/GanttChart/GanttChart.base.tsx | 11 +- .../components/GaugeChart/GaugeChart.base.tsx | 9 +- .../GroupedVerticalBarChart.base.tsx | 9 +- .../HeatMapChart/HeatMapChart.base.tsx | 9 +- .../HorizontalBarChartWithAxis.base.tsx | 9 +- .../src/components/Legends/Legends.base.tsx | 2 +- .../src/components/Legends/Legends.types.ts | 2 +- .../components/LineChart/LineChart.base.tsx | 9 +- .../SankeyChart/SankeyChart.base.tsx | 4 +- .../ScatterChart/ScatterChart.base.tsx | 12 +- .../VerticalBarChart.base.tsx | 9 +- .../VerticalStackedBarChart.base.tsx | 9 +- .../src/utilities/image-export-utils.ts | 238 +++++++++++------- 19 files changed, 290 insertions(+), 165 deletions(-) diff --git a/packages/charts/react-charting/src/components/AnnotationOnlyChart/AnnotationOnlyChart.tsx b/packages/charts/react-charting/src/components/AnnotationOnlyChart/AnnotationOnlyChart.tsx index d192b50d50fbfb..af9ac4de6c4e38 100644 --- a/packages/charts/react-charting/src/components/AnnotationOnlyChart/AnnotationOnlyChart.tsx +++ b/packages/charts/react-charting/src/components/AnnotationOnlyChart/AnnotationOnlyChart.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { useTheme } from '@fluentui/react'; +import { getRTL, useTheme } from '@fluentui/react'; import { mergeStyles } from '@fluentui/react/lib/Styling'; import { ChartAnnotationLayer } from '../CommonComponents/Annotations/ChartAnnotationLayer'; -import { toImage as exportToImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; import type { IAnnotationOnlyChartProps } from './AnnotationOnlyChart.types'; import type { IChart, IImageExportOptions } from '../../types/index'; import type { IChartAnnotationContext } from '../CommonComponents/Annotations/ChartAnnotationLayer.types'; @@ -189,17 +189,13 @@ export const AnnotationOnlyChart: React.FC = props => const chartHandle: IChart = { chartContainer: containerRef.current, toImage: (opts?: IImageExportOptions) => { - if (!containerRef.current) { - return Promise.reject(new Error('Chart container is not defined')); - } - - return exportToImage(containerRef.current, undefined, !!theme?.rtl, opts); + return exportChartsAsImage([{ container: containerRef.current }], undefined, getRTL(), opts); }, }; return chartHandle; }, - [theme?.rtl], + [], ); return ( diff --git a/packages/charts/react-charting/src/components/AreaChart/AreaChart.base.tsx b/packages/charts/react-charting/src/components/AreaChart/AreaChart.base.tsx index 913f2abb8763e7..142d3576c21ff2 100644 --- a/packages/charts/react-charting/src/components/AreaChart/AreaChart.base.tsx +++ b/packages/charts/react-charting/src/components/AreaChart/AreaChart.base.tsx @@ -49,7 +49,7 @@ import { import { ILegend, ILegendContainer, Legends } from '../Legends/index'; import { DirectionalHint } from '@fluentui/react/lib/Callout'; import { IChart, IImageExportOptions } from '../../types/index'; -import { toImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; import { ScaleLinear } from 'd3-scale'; import type { JSXElement } from '@fluentui/utilities'; @@ -305,7 +305,12 @@ export class AreaChartBase extends React.Component => { - return toImage(this._cartesianChartRef.current?.chartContainer, this._legendsRef.current?.toSVG, getRTL(), opts); + return exportChartsAsImage( + [{ container: this._cartesianChartRef.current?.chartContainer }], + this.props.hideLegend ? undefined : this._legendsRef.current?.toSVG, + getRTL(), + opts, + ); }; private _getMinMaxOfYAxis = (points: ILineChartPoints[], yAxisType: YAxisType, useSecondaryYScale: boolean) => diff --git a/packages/charts/react-charting/src/components/ChartTable/ChartTable.base.tsx b/packages/charts/react-charting/src/components/ChartTable/ChartTable.base.tsx index 90effc54e84ae1..de63d9f84e56b3 100644 --- a/packages/charts/react-charting/src/components/ChartTable/ChartTable.base.tsx +++ b/packages/charts/react-charting/src/components/ChartTable/ChartTable.base.tsx @@ -3,7 +3,7 @@ import { IChartTableProps } from './ChartTable.types'; import { IChartTableStyleProps, IChartTableStyles } from '../../index'; import { classNamesFunction, getRTL, initializeComponentRef } from '@fluentui/react/lib/Utilities'; import { IImageExportOptions } from '../../types/index'; -import { toImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; import * as d3 from 'd3-color'; import { getColorContrast } from '../../utilities/colors'; import { ITheme } from '@fluentui/react'; @@ -62,7 +62,7 @@ export class ChartTableBase extends React.Component { this._isRTL = getRTL(props.theme); } public toImage = (opts?: IImageExportOptions): Promise => { - return toImage(this._rootElem, undefined, this._isRTL, opts); + return exportChartsAsImage([{ container: this._rootElem }], undefined, this._isRTL, opts); }; // eslint-disable-next-line @typescript-eslint/no-deprecated diff --git a/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx b/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx index 7f8d4e6ac1d196..f8133c2c309094 100644 --- a/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx +++ b/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import * as React from 'react'; import { useTheme } from '@fluentui/react'; -import { IRefObject } from '@fluentui/react/lib/Utilities'; +import { getRTL, IRefObject } from '@fluentui/react/lib/Utilities'; import { AnnotationOnlyChart } from '../AnnotationOnlyChart/AnnotationOnlyChart'; import { DonutChart } from '../DonutChart/index'; import { VerticalStackedBarChart } from '../VerticalStackedBarChart/index'; @@ -49,8 +49,10 @@ import { ScatterChart } from '../ScatterChart/index'; import { ChartTable } from '../ChartTable/index'; import { FunnelChart } from '../FunnelChart/FunnelChart'; import { GanttChart } from '../GanttChart/index'; -import { ILegendsProps, Legends } from '../Legends/index'; +import { ILegendContainer, ILegendsProps, Legends } from '../Legends/index'; import type { JSXElement } from '@fluentui/utilities'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; +import { resolveCSSVariables } from '../../utilities/utilities'; const ResponsiveDonutChart = withResponsiveContainer(DonutChart); const ResponsiveVerticalStackedBarChart = withResponsiveContainer(VerticalStackedBarChart); @@ -345,8 +347,10 @@ export const DeclarativeChart: React.FunctionComponent = const colorMap = useColorMapping(); const theme = useTheme(); const isDarkTheme = theme?.isInverted ?? false; - const chartRef = React.useRef(null); + const chartRefs = React.useRef<{ compRef: IChart | null; row: number; col: number }[]>([]); const isMultiPlot = React.useRef(false); + const legendsRef = React.useRef(null); + const containerRef = React.useRef(null); if (!isArrayOrTypedArray(selectedLegends)) { selectedLegends = []; @@ -374,12 +378,7 @@ export const DeclarativeChart: React.FunctionComponent = selectedLegends: activeLegends, }; - const baseCommonProps = { - componentRef: chartRef, - }; - const interactiveCommonProps = { - ...baseCommonProps, legendProps: multiSelectLegendProps, calloutProps: { layerProps: { eventBubblingEnabled: true } }, }; @@ -387,28 +386,45 @@ export const DeclarativeChart: React.FunctionComponent = // eslint-disable-next-line @typescript-eslint/no-deprecated function createLegends(legendProps: ILegendsProps): JSXElement { // eslint-disable-next-line react/jsx-no-bind - return ; + return ( + + ); } const exportAsImage = React.useCallback( - (opts?: IImageExportOptions): Promise => { - return new Promise((resolve, reject) => { - if (isMultiPlot.current) { - return reject(Error('Exporting multi plot charts as image is not supported')); - } - if (!chartRef.current || typeof chartRef.current.toImage !== 'function') { - return reject(Error('Chart cannot be exported as image')); + async (opts?: IImageExportOptions) => { + if (!containerRef.current) { + throw new Error('Container reference is null'); + } + + const imgExpOpts = { + background: resolveCSSVariables(containerRef.current, theme.semanticColors.bodyBackground), + scale: 5, + ...opts, + }; + + if (!isMultiPlot.current) { + if (!chartRefs.current[0]?.compRef?.toImage) { + throw new Error('Chart cannot be exported as image'); } - chartRef.current - .toImage({ - background: theme.semanticColors.bodyBackground, - scale: 5, - ...opts, - }) - .then(resolve) - .catch(reject); - }); + return chartRefs.current[0].compRef.toImage(imgExpOpts); + } + + return exportChartsAsImage( + chartRefs.current.map(chart => ({ + container: chart.compRef?.chartContainer, + row: chart.row, + col: chart.col, + })), + legendsRef.current?.toSVG, + getRTL(), + { + background: resolveCSSVariables(containerRef.current, theme.semanticColors.bodyBackground), + scale: 5, + ...opts, + }, + ); }, [theme], ); @@ -510,8 +526,9 @@ export const DeclarativeChart: React.FunctionComponent = gridTemplateRows: gridProperties.templateRows, gridTemplateColumns: gridProperties.templateColumns, }} + ref={containerRef} > - {Object.entries(groupedTraces).map(([xAxisKey, index]) => { + {Object.entries(groupedTraces).map(([xAxisKey, index], chartIdx) => { const plotlyInputForGroup: PlotlySchema = { ...plotlyInputWithValidData, data: index.map(idx => plotlyInputWithValidData.data[idx]), @@ -541,7 +558,7 @@ export const DeclarativeChart: React.FunctionComponent = const resolvedCommonProps = ( chartType === 'annotation' - ? baseCommonProps + ? {} : { ...interactiveCommonProps, xAxisAnnotation: cellProperties?.xAnnotation, @@ -557,6 +574,13 @@ export const DeclarativeChart: React.FunctionComponent = ...resolvedCommonProps, xAxisAnnotation: cellProperties?.xAnnotation, yAxisAnnotation: cellProperties?.yAnnotation, + componentRef: (ref: IChart | null) => { + chartRefs.current[chartIdx] = { + compRef: ref, + row: cellProperties?.row ?? 1, + col: cellProperties?.column ?? 1, + }; + }, }, cellProperties?.row ?? 1, cellProperties?.column ?? 1, diff --git a/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx b/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx index 5a7cef23aa0a2d..285ee9bb8968a2 100644 --- a/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx +++ b/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx @@ -17,7 +17,7 @@ import { } from '../../utilities/index'; import { formatToLocaleString } from '@fluentui/chart-utilities'; import { IChart, IImageExportOptions } from '../../types/index'; -import { toImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; import { ILegendContainer } from '../Legends/index'; import type { JSXElement } from '@fluentui/utilities'; @@ -234,7 +234,12 @@ export class DonutChartBase extends React.Component => { - return toImage(this._rootElem, this._legendsRef.current?.toSVG, getRTL(), opts); + return exportChartsAsImage( + [{ container: this._rootElem }], + this.props.hideLegend ? undefined : this._legendsRef.current?.toSVG, + getRTL(), + opts, + ); }; private _closeCallout = () => { diff --git a/packages/charts/react-charting/src/components/FunnelChart/FunnelChart.base.tsx b/packages/charts/react-charting/src/components/FunnelChart/FunnelChart.base.tsx index 03324f7500f1c8..b11e8ccc3864de 100644 --- a/packages/charts/react-charting/src/components/FunnelChart/FunnelChart.base.tsx +++ b/packages/charts/react-charting/src/components/FunnelChart/FunnelChart.base.tsx @@ -12,7 +12,7 @@ import { Callout, DirectionalHint } from '@fluentui/react/lib/Callout'; import { ChartHoverCard } from '../../utilities/ChartHoverCard/ChartHoverCard'; import { formatToLocaleString } from '@fluentui/chart-utilities'; import { IImageExportOptions } from '../../types/index'; -import { toImage as convertToImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; import { getContrastTextColor } from '../../utilities/utilities'; import { getHorizontalFunnelSegmentGeometry, @@ -53,10 +53,15 @@ export const FunnelChartBase: React.FunctionComponent = React () => ({ chartContainer: chartContainerRef ?? null, toImage: (opts?: IImageExportOptions): Promise => { - return convertToImage(chartContainerRef.current, legendsRef.current?.toSVG, getRTL(), opts); + return exportChartsAsImage( + [{ container: chartContainerRef.current }], + props.hideLegend ? undefined : legendsRef.current?.toSVG, + getRTL(), + opts, + ); }, }), - [], + [props.hideLegend], ); function _handleHover(data: IFunnelChartDataPoint, mouseEvent: React.MouseEvent) { diff --git a/packages/charts/react-charting/src/components/GanttChart/GanttChart.base.tsx b/packages/charts/react-charting/src/components/GanttChart/GanttChart.base.tsx index 4f214b863ad201..340cdf43b7af23 100644 --- a/packages/charts/react-charting/src/components/GanttChart/GanttChart.base.tsx +++ b/packages/charts/react-charting/src/components/GanttChart/GanttChart.base.tsx @@ -26,7 +26,7 @@ import { getScalePadding, getDateFormatLevel, } from '../../utilities/index'; -import { toImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; import { formatDateToLocaleString, getMultiLevelDateTimeFormatOptions } from '@fluentui/chart-utilities'; import type { JSXElement } from '@fluentui/utilities'; @@ -73,10 +73,15 @@ export const GanttChartBase: React.FunctionComponent = React.f () => ({ chartContainer: _cartesianChartRef.current?.chartContainer ?? null, toImage: (opts?: IImageExportOptions): Promise => { - return toImage(_cartesianChartRef.current?.chartContainer, _legendsRef.current?.toSVG, getRTL(), opts); + return exportChartsAsImage( + [{ container: _cartesianChartRef.current?.chartContainer }], + props.hideLegend ? undefined : _legendsRef.current?.toSVG, + getRTL(), + opts, + ); }, }), - [], + [props.hideLegend], ); const _points = React.useMemo(() => { diff --git a/packages/charts/react-charting/src/components/GaugeChart/GaugeChart.base.tsx b/packages/charts/react-charting/src/components/GaugeChart/GaugeChart.base.tsx index c3f2d2e07d8606..88b9b82f103293 100644 --- a/packages/charts/react-charting/src/components/GaugeChart/GaugeChart.base.tsx +++ b/packages/charts/react-charting/src/components/GaugeChart/GaugeChart.base.tsx @@ -28,7 +28,7 @@ import { IYValueHover } from '../../index'; import { SVGTooltipText } from '../../utilities/SVGTooltipText'; import { select as d3Select } from 'd3-selection'; import { IChart, IImageExportOptions } from '../../types/index'; -import { toImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; const GAUGE_MARGIN = 16; const LABEL_WIDTH = 36; @@ -364,7 +364,12 @@ export class GaugeChartBase extends React.Component => { - return toImage(this._rootElem, this._legendsRef.current?.toSVG, this._isRTL, opts); + return exportChartsAsImage( + [{ container: this._rootElem }], + this.props.hideLegend ? undefined : this._legendsRef.current?.toSVG, + this._isRTL, + opts, + ); }; private _getMargins = () => { diff --git a/packages/charts/react-charting/src/components/GroupedVerticalBarChart/GroupedVerticalBarChart.base.tsx b/packages/charts/react-charting/src/components/GroupedVerticalBarChart/GroupedVerticalBarChart.base.tsx index 8c6540bd21e796..e790f9a673290e 100644 --- a/packages/charts/react-charting/src/components/GroupedVerticalBarChart/GroupedVerticalBarChart.base.tsx +++ b/packages/charts/react-charting/src/components/GroupedVerticalBarChart/GroupedVerticalBarChart.base.tsx @@ -56,7 +56,7 @@ import { IYValueHover, } from '../../index'; import { IChart, IImageExportOptions, IBarSeries, ILineSeries } from '../../types/index'; -import { toImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; import { ILegendContainer } from '../Legends/index'; import { rgb } from 'd3-color'; import type { JSXElement } from '@fluentui/utilities'; @@ -288,7 +288,12 @@ export class GroupedVerticalBarChartBase } public toImage = (opts?: IImageExportOptions): Promise => { - return toImage(this._cartesianChartRef.current?.chartContainer, this._legendsRef.current?.toSVG, this._isRtl, opts); + return exportChartsAsImage( + [{ container: this._cartesianChartRef.current?.chartContainer }], + this.props.hideLegend ? undefined : this._legendsRef.current?.toSVG, + this._isRtl, + opts, + ); }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/charts/react-charting/src/components/HeatMapChart/HeatMapChart.base.tsx b/packages/charts/react-charting/src/components/HeatMapChart/HeatMapChart.base.tsx index 1ab80d09b85298..9b824287ff7870 100644 --- a/packages/charts/react-charting/src/components/HeatMapChart/HeatMapChart.base.tsx +++ b/packages/charts/react-charting/src/components/HeatMapChart/HeatMapChart.base.tsx @@ -38,7 +38,7 @@ import { import { Target } from '@fluentui/react'; import { format as d3Format } from 'd3-format'; import { timeFormat as d3TimeFormat } from 'd3-time-format'; -import { toImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; import type { JSXElement } from '@fluentui/utilities'; type DataSet = { @@ -282,7 +282,12 @@ export class HeatMapChartBase extends React.Component => { - return toImage(this._cartesianChartRef.current?.chartContainer, this._legendsRef.current?.toSVG, getRTL(), opts); + return exportChartsAsImage( + [{ container: this._cartesianChartRef.current?.chartContainer }], + this.props.hideLegend ? undefined : this._legendsRef.current?.toSVG, + getRTL(), + opts, + ); }; private _getMinMaxOfYAxis = () => { diff --git a/packages/charts/react-charting/src/components/HorizontalBarChartWithAxis/HorizontalBarChartWithAxis.base.tsx b/packages/charts/react-charting/src/components/HorizontalBarChartWithAxis/HorizontalBarChartWithAxis.base.tsx index 93700c75a46e63..02f24f9325a6cc 100644 --- a/packages/charts/react-charting/src/components/HorizontalBarChartWithAxis/HorizontalBarChartWithAxis.base.tsx +++ b/packages/charts/react-charting/src/components/HorizontalBarChartWithAxis/HorizontalBarChartWithAxis.base.tsx @@ -46,7 +46,7 @@ import { MIN_DOMAIN_MARGIN, sortAxisCategories, } from '../../utilities/index'; -import { toImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; import { getClosestPairDiffAndRange } from '../../utilities/vbc-utils'; import type { JSXElement } from '@fluentui/utilities'; @@ -222,7 +222,12 @@ export class HorizontalBarChartWithAxisBase } public toImage = (opts?: IImageExportOptions): Promise => { - return toImage(this._cartesianChartRef.current?.chartContainer, this._legendsRef.current?.toSVG, this._isRtl, opts); + return exportChartsAsImage( + [{ container: this._cartesianChartRef.current?.chartContainer }], + this.props.hideLegend ? undefined : this._legendsRef.current?.toSVG, + this._isRtl, + opts, + ); }; private _getDomainNRangeValues = ( diff --git a/packages/charts/react-charting/src/components/Legends/Legends.base.tsx b/packages/charts/react-charting/src/components/Legends/Legends.base.tsx index 1f65d1b64d070a..aa9f767f5f9585 100644 --- a/packages/charts/react-charting/src/components/Legends/Legends.base.tsx +++ b/packages/charts/react-charting/src/components/Legends/Legends.base.tsx @@ -128,7 +128,7 @@ export class LegendsBase extends React.Component im svgWidth: number, isRTL: boolean = false, ): { - node: SVGGElement | null; + node: SVGSVGElement | null; width: number; height: number; } => { diff --git a/packages/charts/react-charting/src/components/Legends/Legends.types.ts b/packages/charts/react-charting/src/components/Legends/Legends.types.ts index f272c2a68403f8..7c2c4eda6b1b21 100644 --- a/packages/charts/react-charting/src/components/Legends/Legends.types.ts +++ b/packages/charts/react-charting/src/components/Legends/Legends.types.ts @@ -303,7 +303,7 @@ export interface ILegendContainer { svgWidth: number, isRTL?: boolean, ) => { - node: SVGGElement | null; + node: SVGSVGElement | null; width: number; height: number; }; diff --git a/packages/charts/react-charting/src/components/LineChart/LineChart.base.tsx b/packages/charts/react-charting/src/components/LineChart/LineChart.base.tsx index d188b799e782c5..5863c4006feb78 100644 --- a/packages/charts/react-charting/src/components/LineChart/LineChart.base.tsx +++ b/packages/charts/react-charting/src/components/LineChart/LineChart.base.tsx @@ -61,7 +61,7 @@ import { findCalloutPoints, } from '../../utilities/index'; import { IChart, IImageExportOptions } from '../../types/index'; -import { toImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; import { ScaleLinear } from 'd3-scale'; import { renderScatterPolarCategoryLabels } from '../../utilities/scatterpolar-utils'; import type { JSXElement } from '@fluentui/utilities'; @@ -415,7 +415,12 @@ export class LineChartBase extends React.Component => { - return toImage(this._cartesianChartRef.current?.chartContainer, this._legendsRef.current?.toSVG, this._isRTL, opts); + return exportChartsAsImage( + [{ container: this._cartesianChartRef.current?.chartContainer }], + this.props.hideLegend ? undefined : this._legendsRef.current?.toSVG, + this._isRTL, + opts, + ); }; private _getDomainNRangeValues = ( diff --git a/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.base.tsx b/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.base.tsx index 0eead6aef93a29..10525d6f050897 100644 --- a/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.base.tsx +++ b/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.base.tsx @@ -27,7 +27,7 @@ import { ISankeyChartStyleProps, ISankeyChartStyles, } from './SankeyChart.types'; -import { toImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; const getClassNames = classNamesFunction(); const PADDING_PERCENTAGE = 0.3; @@ -901,7 +901,7 @@ export class SankeyChartBase extends React.Component => { - return toImage(this.chartContainer, undefined, this._isRtl, opts); + return exportChartsAsImage([{ container: this.chartContainer }], undefined, this._isRtl, opts); }; private _computeNodeAttributes( diff --git a/packages/charts/react-charting/src/components/ScatterChart/ScatterChart.base.tsx b/packages/charts/react-charting/src/components/ScatterChart/ScatterChart.base.tsx index 342a14b8a6a910..4c7fa5ccc3f101 100644 --- a/packages/charts/react-charting/src/components/ScatterChart/ScatterChart.base.tsx +++ b/packages/charts/react-charting/src/components/ScatterChart/ScatterChart.base.tsx @@ -42,7 +42,7 @@ import { import { classNamesFunction, DirectionalHint, getId, getRTL } from '@fluentui/react'; import { IImageExportOptions, IScatterChartDataPoint, IScatterChartPoints } from '../../types/index'; import { ILineChartPoints } from '../../types/IDataPoint'; -import { toImage as convertToImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; import { formatDateToLocaleString } from '@fluentui/chart-utilities'; import { renderScatterPolarCategoryLabels } from '../../utilities/scatterpolar-utils'; import type { JSXElement } from '@fluentui/utilities'; @@ -120,10 +120,15 @@ export const ScatterChartBase: React.FunctionComponent = Rea () => ({ chartContainer: cartesianChartRef.current?.chartContainer ?? null, toImage: (opts?: IImageExportOptions): Promise => { - return convertToImage(cartesianChartRef.current?.chartContainer, legendsRef.current?.toSVG, getRTL(), opts); + return exportChartsAsImage( + [{ container: cartesianChartRef.current?.chartContainer }], + props.hideLegend ? undefined : legendsRef.current?.toSVG, + getRTL(), + opts, + ); }, }), - [], + [props.hideLegend], ); const _xAxisType: XAxisTypes = @@ -223,6 +228,7 @@ export const ScatterChartBase: React.FunctionComponent = Rea return ( => { - return toImage(this._cartesianChartRef.current?.chartContainer, this._legendsRef.current?.toSVG, this._isRtl, opts); + return exportChartsAsImage( + [{ container: this._cartesianChartRef.current?.chartContainer }], + this.props.hideLegend ? undefined : this._legendsRef.current?.toSVG, + this._isRtl, + opts, + ); }; private _getDomainNRangeValues = ( diff --git a/packages/charts/react-charting/src/components/VerticalStackedBarChart/VerticalStackedBarChart.base.tsx b/packages/charts/react-charting/src/components/VerticalStackedBarChart/VerticalStackedBarChart.base.tsx index 79b97edc333489..7469adb83648c3 100644 --- a/packages/charts/react-charting/src/components/VerticalStackedBarChart/VerticalStackedBarChart.base.tsx +++ b/packages/charts/react-charting/src/components/VerticalStackedBarChart/VerticalStackedBarChart.base.tsx @@ -68,7 +68,7 @@ import { calcRequiredWidth, } from '../../utilities/index'; import { IChart, IImageExportOptions } from '../../types/index'; -import { toImage } from '../../utilities/image-export-utils'; +import { exportChartsAsImage } from '../../utilities/image-export-utils'; import type { JSXElement } from '@fluentui/utilities'; const getClassNames = classNamesFunction(); @@ -314,7 +314,12 @@ export class VerticalStackedBarChartBase } public toImage = (opts?: IImageExportOptions): Promise => { - return toImage(this._cartesianChartRef.current?.chartContainer, this._legendsRef.current?.toSVG, this._isRtl, opts); + return exportChartsAsImage( + [{ container: this._cartesianChartRef.current?.chartContainer }], + this.props.hideLegend ? undefined : this._legendsRef.current?.toSVG, + this._isRtl, + opts, + ); }; /** diff --git a/packages/charts/react-charting/src/utilities/image-export-utils.ts b/packages/charts/react-charting/src/utilities/image-export-utils.ts index f7c3500d22e7eb..0449a3dc24fa30 100644 --- a/packages/charts/react-charting/src/utilities/image-export-utils.ts +++ b/packages/charts/react-charting/src/utilities/image-export-utils.ts @@ -1,5 +1,5 @@ import { create as d3Create, select as d3Select, Selection } from 'd3-selection'; -import { copyStyle, createMeasurementSpan, resolveCSSVariables } from './index'; +import { copyStyle, createMeasurementSpan } from './index'; import { IImageExportOptions } from '../types/index'; import { ILegend, ILegendContainer } from '../Legends'; import { @@ -13,48 +13,64 @@ import { INACTIVE_LEGEND_TEXT_OPACITY, } from '../components/Legends/Legends.styles'; -export function toImage( - chartContainer: HTMLElement | null | undefined, - legendsToSvgCallback?: ILegendContainer['toSVG'], +export type GridChart = { + container: HTMLElement | null | undefined; + row?: number; + col?: number; +}; + +type SvgImage = { + dataUrl: string; + width: number; + height: number; +}; + +export async function exportChartsAsImage( + charts: GridChart[], + legendsToSvg?: ILegendContainer['toSVG'], isRTL: boolean = false, opts: IImageExportOptions = {}, ): Promise { - return new Promise((resolve, reject) => { - if (!chartContainer) { - return reject(new Error('Chart container is not defined')); + if (charts.length === 0 && !legendsToSvg) { + throw new Error('No charts or legends to export'); + } + + const chartImages = await Promise.all( + charts.map(chart => { + return new Promise(resolve => { + const svg = cloneStyledSVG(chart.container, isRTL); + const svgDataUrl = svgToBase64(svg.node); + resolve({ dataUrl: svgDataUrl, width: svg.width, height: svg.height }); + }); + }), + ); + + const grid: SvgImage[][] = []; // Sparse 2D array + charts.forEach((chart, i) => { + const row = chart.row || 0; + const col = chart.col || 0; + if (!grid[row]) { + grid[row] = []; } + grid[row][col] = chartImages[i]; + }); - try { - const background = - typeof opts.background === 'string' ? resolveCSSVariables(chartContainer, opts.background) : 'transparent'; + if (legendsToSvg) { + let totalWidth = 0; + grid.forEach(row => { + let rowWidth = 0; + row.forEach(item => { + rowWidth += item.width; + }); + totalWidth = Math.max(totalWidth, rowWidth); + }); - const svg = toSVG(chartContainer, legendsToSvgCallback, isRTL, background); - if (!svg.node) { - return reject(new Error('SVG node is null')); - } + const svg = legendsToSvg(totalWidth, isRTL); + const svgDataUrl = svgToBase64(svg.node); + grid.push([{ dataUrl: svgDataUrl, width: svg.width, height: svg.height }]); + } - let svgData = new XMLSerializer().serializeToString(svg.node); - // This node is already detached from the DOM, so there's no need to call remove() on it. - // Just clear the reference. - svg.node = null; - - let svgDataUrl = 'data:image/svg+xml;base64,' + btoa(unescapePonyfill(encodeURIComponent(svgData))); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - svgData = null as any; - - svgToPng(svgDataUrl, { - width: opts.width || svg.width, - height: opts.height || svg.height, - scale: opts.scale, - }) - .then(resolve) - .catch(reject); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - svgDataUrl = null as any; - } catch (err) { - return reject(err); - } - }); + return svgToPng(grid, opts); } const SVG_STYLE_PROPERTIES = [ @@ -125,12 +141,11 @@ const ANNOTATION_HTML_STYLE_PROPERTIES = [ ]; const ANNOTATION_FOREIGN_OBJECT_STYLE_PROPERTIES = ['overflow', 'pointer-events']; -function toSVG( - chartContainer: HTMLElement, - legendsToSvgCallback: ILegendContainer['toSVG'] | undefined, - isRTL: boolean, - background: string, -) { +function cloneStyledSVG(chartContainer: HTMLElement | null | undefined, isRTL: boolean) { + if (!chartContainer) { + throw new Error('Chart container is not defined'); + } + const svg = chartContainer.querySelector('svg'); if (!svg) { throw new Error('SVG not found'); @@ -188,14 +203,6 @@ function toSVG( }); }); - const { width: svgWidth, height: svgHeight } = svg.getBoundingClientRect(); - const legendGroup = - typeof legendsToSvgCallback === 'function' - ? legendsToSvgCallback(svgWidth, isRTL) - : { node: null, width: 0, height: 0 }; - const w1 = Math.max(svgWidth, legendGroup.width); - const h1 = svgHeight + legendGroup.height; - const annotationSvg = chartContainer.querySelector('[data-chart-annotation-svg="true"]'); let annotationClone: SVGSVGElement | null = null; @@ -232,33 +239,24 @@ function toSVG( } } - if (legendGroup.node) { - d3Select(legendGroup.node).attr('transform', `translate(0, ${svgHeight})`); - clonedSvg.append(() => legendGroup.node); - } - clonedSvg - .insert('rect', ':first-child') - .attr('x', 0) - .attr('y', 0) - .attr('width', w1) - .attr('height', h1) - .attr('fill', background); + const { width, height } = svg.getBoundingClientRect(); + clonedSvg - .attr('width', w1) - .attr('height', h1) - .attr('viewBox', `0 0 ${w1} ${h1}`) + .attr('width', width) + .attr('height', height) + .attr('viewBox', `0 0 ${width} ${height}`) .attr('direction', isRTL ? 'rtl' : 'ltr'); if (annotationClone) { clonedSvg.selectAll('[data-chart-annotation-layer="true"]').remove(); - d3Select(annotationClone).attr('x', 0).attr('y', 0).attr('width', svgWidth).attr('height', svgHeight); + d3Select(annotationClone).attr('x', 0).attr('y', 0).attr('width', width).attr('height', height); clonedSvg.append(() => annotationClone as SVGSVGElement); } const result = { node: clonedSvg.node(), - width: w1, - height: h1, + width, + height, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any clonedSvg = null as any; @@ -283,7 +281,7 @@ export function cloneLegendsToSVG( isRTL: boolean; }, legendContainer?: HTMLElement | null, -): { node: SVGGElement | null; width: number; height: number } { +): { node: SVGSVGElement | null; width: number; height: number } { if (legends.length === 0) { return { node: null, @@ -372,46 +370,92 @@ export function cloneLegendsToSVG( }); } + const w1 = Math.max(svgWidth, ...legendLineWidths); + const h1 = legendY; + const svg = d3Create('svg').attr('width', w1).attr('height', h1).attr('viewBox', `0 0 ${w1} ${h1}`); + svg.append(() => legendGroup.node()!); + return { - node: legendGroup.node(), - width: Math.max(...legendLineWidths), - height: legendY, + node: svg.node(), + width: w1, + height: h1, }; } -function svgToPng(svgDataUrl: string, opts: IImageExportOptions = {}): Promise { - return new Promise((resolve, reject) => { - const scale = opts.scale || 1; - const w0 = opts.width || 300; - const h0 = opts.height || 150; - const w1 = scale * w0; - const h1 = scale * h0; +type PositionedImage = SvgImage & { + x: number; + y: number; +}; - const canvas = document.createElement('canvas'); - const img = new Image(); +async function svgToPng(grid: SvgImage[][], opts: IImageExportOptions = {}): Promise { + let totalWidth = 0; + let totalHeight = 0; - canvas.width = w1; - canvas.height = h1; + const positionedImages: PositionedImage[] = grid + .map(row => { + let rowWidth = 0; + let rowHeight = 0; - img.onload = function () { - const ctx = canvas.getContext('2d'); - if (!ctx) { - return reject(new Error('Canvas context is null')); - } + const items = row.map(item => { + const positioned = { ...item, x: rowWidth, y: totalHeight }; + rowWidth += item.width; + rowHeight = Math.max(rowHeight, item.height); + return positioned; + }); - ctx.clearRect(0, 0, w1, h1); - ctx.drawImage(img, 0, 0, w1, h1); + totalWidth = Math.max(totalWidth, rowWidth); + totalHeight += rowHeight; - const imgData = canvas.toDataURL('image/png'); - resolve(imgData); - }; + return items; + }) + .flat(); - img.onerror = function (err) { - reject(err); - }; + const scale = opts.scale || 1; + const w0 = opts.width || totalWidth; + const h0 = opts.height || totalHeight; + const scaleX = (scale * w0) / totalWidth; + const scaleY = (scale * h0) / totalHeight; + totalWidth = scaleX * totalWidth; + totalHeight = scaleY * totalHeight; - img.src = svgDataUrl; - }); + const canvas = document.createElement('canvas'); + canvas.width = totalWidth; + canvas.height = totalHeight; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Canvas context is null'); + } + + ctx.fillStyle = opts.background || 'transparent'; + ctx.fillRect(0, 0, totalWidth, totalHeight); + + await Promise.all( + positionedImages.map( + item => + new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + ctx.drawImage(img, scaleX * item.x, scaleY * item.y, scaleX * item.width, scaleY * item.height); + resolve(); + }; + img.onerror = reject; + img.src = item.dataUrl; + }), + ), + ); + + return canvas.toDataURL('image/png'); +} + +function svgToBase64(svgNode: SVGSVGElement | null) { + if (!svgNode) { + throw new Error('SVG node is null'); + } + + const svgData = new XMLSerializer().serializeToString(svgNode); + const svgDataUrl = 'data:image/svg+xml;base64,' + btoa(unescapePonyfill(encodeURIComponent(svgData))); + return svgDataUrl; } const hex2 = /^[\da-f]{2}$/i; From 9e6318db1647b9d8585e779fc78fd33026188a16 Mon Sep 17 00:00:00 2001 From: Kumar Kshitij Date: Tue, 11 Nov 2025 00:05:45 +0530 Subject: [PATCH 2/3] add change file --- ...eact-charting-5834c1f0-46f1-421b-b9c0-d755d1925d76.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-react-charting-5834c1f0-46f1-421b-b9c0-d755d1925d76.json diff --git a/change/@fluentui-react-charting-5834c1f0-46f1-421b-b9c0-d755d1925d76.json b/change/@fluentui-react-charting-5834c1f0-46f1-421b-b9c0-d755d1925d76.json new file mode 100644 index 00000000000000..d117f74a13860f --- /dev/null +++ b/change/@fluentui-react-charting-5834c1f0-46f1-421b-b9c0-d755d1925d76.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: enable multiplot image export", + "packageName": "@fluentui/react-charting", + "email": "kumarkshitij@microsoft.com", + "dependentChangeType": "patch" +} From 94c0b561cf0af1a96dc07a559bb89d2800b22be4 Mon Sep 17 00:00:00 2001 From: Kumar Kshitij Date: Tue, 11 Nov 2025 00:35:59 +0530 Subject: [PATCH 3/3] fix lint errors --- .../DeclarativeChart/DeclarativeChart.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx b/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx index f8133c2c309094..e43c5de5fe92ec 100644 --- a/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx +++ b/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx @@ -321,7 +321,7 @@ const chartMap: ChartTypeMap = { export const DeclarativeChart: React.FunctionComponent = React.forwardRef< HTMLDivElement, DeclarativeChartProps ->(({ colorwayType = 'default', ...props }, forwardedRef) => { +>(({ colorwayType = 'default', onSchemaChange, ...props }, forwardedRef) => { const { plotlySchema } = sanitizeJson(props.chartSchema); const chart: OutputChartType = mapFluentChart(plotlySchema); if (!chart.isValid) { @@ -357,12 +357,15 @@ export const DeclarativeChart: React.FunctionComponent = } const [activeLegends, setActiveLegends] = React.useState(selectedLegends); - const onActiveLegendsChange = (keys: string[]) => { - setActiveLegends(keys); - if (props.onSchemaChange) { - props.onSchemaChange({ plotlySchema: { plotlyInput, selectedLegends: keys } }); - } - }; + const onActiveLegendsChange = React.useCallback( + (keys: string[]) => { + setActiveLegends(keys); + if (onSchemaChange) { + onSchemaChange({ plotlySchema: { plotlyInput, selectedLegends: keys } }); + } + }, + [onSchemaChange, plotlyInput], + ); React.useEffect(() => { // eslint-disable-next-line @typescript-eslint/no-shadow @@ -412,10 +415,10 @@ export const DeclarativeChart: React.FunctionComponent = } return exportChartsAsImage( - chartRefs.current.map(chart => ({ - container: chart.compRef?.chartContainer, - row: chart.row, - col: chart.col, + chartRefs.current.map(item => ({ + container: item.compRef?.chartContainer, + row: item.row, + col: item.col, })), legendsRef.current?.toSVG, getRTL(),