diff --git a/change/@fluentui-react-charting-8d924304-4078-4192-ba38-ec72171e24ca.json b/change/@fluentui-react-charting-8d924304-4078-4192-ba38-ec72171e24ca.json new file mode 100644 index 00000000000000..de5d1d1d7930c0 --- /dev/null +++ b/change/@fluentui-react-charting-8d924304-4078-4192-ba38-ec72171e24ca.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add functionality to export chart as image", + "packageName": "@fluentui/react-charting", + "email": "110246001+krkshitij@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/charts/react-charting/etc/react-charting.api.md b/packages/charts/react-charting/etc/react-charting.api.md index 54135c0d0cea04..f07cb027b93031 100644 --- a/packages/charts/react-charting/etc/react-charting.api.md +++ b/packages/charts/react-charting/etc/react-charting.api.md @@ -12,6 +12,7 @@ import { IFocusZoneProps } from '@fluentui/react-focus'; import { IHoverCardStyleProps } from '@fluentui/react/lib/HoverCard'; import { IHoverCardStyles } from '@fluentui/react/lib/HoverCard'; import { IOverflowSetProps } from '@fluentui/react/lib/OverflowSet'; +import { IRefObject } from '@fluentui/react/lib/Utilities'; import { IRenderFunction } from '@fluentui/react/lib/Utilities'; import { IStyle } from '@fluentui/react/lib/Styling'; import { IStyle as IStyle_2 } from '@fluentui/react'; @@ -125,6 +126,7 @@ export const DeclarativeChart: React_2.FunctionComponent; // @public export interface DeclarativeChartProps extends React_2.RefAttributes { chartSchema: Schema; + componentRef?: IRefObject; onSchemaChange?: (eventData: Schema) => void; } @@ -267,6 +269,7 @@ export interface ICartesianChartProps { // @deprecated chartLabel?: string; className?: string; + componentRef?: IRefObject; customDateTimeFormatter?: (dateTime: Date) => string; dateLocalizeOptions?: Intl.DateTimeFormatOptions; enabledLegendsWrapLines?: boolean; @@ -352,6 +355,12 @@ export interface ICartesianChartStyles { yAxis?: IStyle; } +// @public (undocumented) +export interface IChart { + // (undocumented) + chartContainer: HTMLElement | null; +} + // @public (undocumented) export interface IChartDataPoint { callOutAccessibilityData?: IAccessibilityProps; @@ -481,6 +490,12 @@ export interface IDataPoint { y: number; } +// @public (undocumented) +export interface IDeclarativeChart { + // (undocumented) + exportAsImage: (opts?: IImageExportOptions) => Promise; +} + // @public (undocumented) export interface IDonutChart { } @@ -488,6 +503,7 @@ export interface IDonutChart { // @public export interface IDonutChartProps extends ICartesianChartProps { calloutProps?: Partial; + componentRef?: IRefObject; culture?: string; data?: IChartProps; enableGradient?: boolean; @@ -536,6 +552,7 @@ export interface IGaugeChartProps { chartValue: number; chartValueFormat?: GaugeValueFormat | ((sweepFraction: [number, number]) => string); className?: string; + componentRef?: IRefObject; culture?: string; enableGradient?: boolean; height?: number; @@ -832,6 +849,18 @@ export interface IHorizontalDataPoint { y: number; } +// @public (undocumented) +export interface IImageExportOptions { + // (undocumented) + background?: string; + // (undocumented) + height?: number; + // (undocumented) + scale?: number; + // (undocumented) + width?: number; +} + // @public export interface ILegend { action?: VoidFunction; @@ -1068,6 +1097,7 @@ export interface IModifiedCartesianChartProps extends ICartesianChartProps { maxOfYVal?: number; onChartMouseLeave?: () => void; points: any; + ref?: IRefObject; showYAxisLables?: boolean; showYAxisLablesTooltip?: boolean; stringDatasetForYAxisDomain?: string[]; @@ -1200,6 +1230,7 @@ export interface ISankeyChartProps { borderColorsForNodes?: string[]; className?: string; colorsForNodes?: string[]; + componentRef?: IRefObject; data: IChartProps; enableReflow?: boolean; formatNumberOptions?: Intl.NumberFormatOptions; diff --git a/packages/charts/react-charting/src/DeclarativeChart.ts b/packages/charts/react-charting/src/DeclarativeChart.ts index dadcd454679132..ca97cd8fc995f6 100644 --- a/packages/charts/react-charting/src/DeclarativeChart.ts +++ b/packages/charts/react-charting/src/DeclarativeChart.ts @@ -1 +1 @@ -export * from './components/DeclarativeChart/DeclarativeChart'; +export * from './components/DeclarativeChart/index'; 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 686fe0babe863b..578261e3460847 100644 --- a/packages/charts/react-charting/src/components/AreaChart/AreaChart.base.tsx +++ b/packages/charts/react-charting/src/components/AreaChart/AreaChart.base.tsx @@ -3,7 +3,13 @@ import { max as d3Max, bisector } from 'd3-array'; import { pointer } from 'd3-selection'; import { select as d3Select } from 'd3-selection'; import { area as d3Area, stack as d3Stack, curveMonotoneX as d3CurveBasis, line as d3Line } from 'd3-shape'; -import { classNamesFunction, find, getId, memoizeFunction } from '@fluentui/react/lib/Utilities'; +import { + classNamesFunction, + find, + getId, + initializeComponentRef, + memoizeFunction, +} from '@fluentui/react/lib/Utilities'; import { IAccessibilityProps, CartesianChart, @@ -38,6 +44,7 @@ import { } from '../../utilities/index'; import { ILegend, Legends } from '../Legends/index'; import { DirectionalHint } from '@fluentui/react/lib/Callout'; +import { IChart } from '../../types/index'; const getClassNames = classNamesFunction(); @@ -82,7 +89,7 @@ export interface IAreaChartState extends IBasestate { activePoint: string; } -export class AreaChartBase extends React.Component { +export class AreaChartBase extends React.Component implements IChart { public static defaultProps: Partial = { useUTC: true, }; @@ -119,9 +126,13 @@ export class AreaChartBase extends React.Component; public constructor(props: IAreaChartProps) { super(props); + + initializeComponentRef(this); + this._createSet = memoizeFunction(this._createDataSet); this.state = { selectedLegend: props.legendProps?.selectedLegend ?? '', @@ -148,6 +159,7 @@ export class AreaChartBase extends React.Component { @@ -249,6 +262,10 @@ export class AreaChartBase extends React.Component(); const ChartHoverCard = React.lazy(() => @@ -63,9 +64,12 @@ export interface ICartesianChartState { * 2.Callout * 3.Fit parent Continer */ -export class CartesianChartBase extends React.Component { +export class CartesianChartBase + extends React.Component + implements IChart +{ + public chartContainer: HTMLDivElement; private _classNames: IProcessedStyleSet; - private chartContainer: HTMLDivElement; private legendContainer: HTMLDivElement; private minLegendContainerHeight: number = 32; private xAxisElement: SVGSVGElement | null; @@ -619,6 +623,7 @@ export class CartesianChartBase extends React.Component ); } + /** * Dedicated function to return the Callout JSX Element , which can further be used to only call this when * only the calloutprops and charthover props changes. diff --git a/packages/charts/react-charting/src/components/CommonComponents/CartesianChart.types.ts b/packages/charts/react-charting/src/components/CommonComponents/CartesianChart.types.ts index 227d38446e64e9..d9363dd636742e 100644 --- a/packages/charts/react-charting/src/components/CommonComponents/CartesianChart.types.ts +++ b/packages/charts/react-charting/src/components/CommonComponents/CartesianChart.types.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import { IStyleFunctionOrObject } from '@fluentui/react/lib/Utilities'; +import { IRefObject, IStyleFunctionOrObject } from '@fluentui/react/lib/Utilities'; import { ITheme, IStyle } from '@fluentui/react/lib/Styling'; import { IOverflowSetProps } from '@fluentui/react/lib/OverflowSet'; import { IFocusZoneProps, FocusZoneDirection } from '@fluentui/react-focus'; @@ -7,6 +7,7 @@ import { ICalloutProps } from '@fluentui/react/lib/Callout'; import { ILegendsProps } from '../Legends/index'; import { IAccessibilityProps, + IChart, IDataPoint, IGroupedVerticalBarChartData, IHeatMapChartDataPoint, @@ -445,6 +446,12 @@ export interface ICartesianChartProps { * Used for enabling negative values in Y axis. */ supportNegativeData?: boolean; + + /** + * Optional callback to access the IChart interface. Use this instead of ref for accessing + * the public methods and properties of the component. + */ + componentRef?: IRefObject; } export interface IYValueHover { @@ -690,4 +697,9 @@ export interface IModifiedCartesianChartProps extends ICartesianChartProps { isRtl: boolean, barWidth: number | undefined, ) => ScaleBand; + + /** + * Callback to access the public methods and properties of the component. + */ + ref?: IRefObject; } diff --git a/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx b/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx index c75490160da75b..9929b993f178ae 100644 --- a/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx +++ b/packages/charts/react-charting/src/components/DeclarativeChart/DeclarativeChart.tsx @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as React from 'react'; +import { useTheme } from '@fluentui/react'; +import { IRefObject } from '@fluentui/react/lib/Utilities'; import { DonutChart } from '../DonutChart/index'; import { VerticalStackedBarChart } from '../VerticalStackedBarChart/index'; import { @@ -24,12 +26,8 @@ import { SankeyChart } from '../SankeyChart/SankeyChart'; import { GaugeChart } from '../GaugeChart/index'; import { GroupedVerticalBarChart } from '../GroupedVerticalBarChart/index'; import { VerticalBarChart } from '../VerticalBarChart/index'; -import { useTheme } from '@fluentui/react/lib/Theme'; - -export const UseIsDarkTheme = (): boolean => { - const theme = useTheme(); - return theme?.isInverted ?? false; -}; +import { IImageExportOptions, toImage } from './imageExporter'; +import { IChart } from '../../types/index'; /** * DeclarativeChart schema. @@ -56,6 +54,19 @@ export interface DeclarativeChartProps extends React.RefAttributes void; + + /** + * Optional callback to access the IDeclarativeChart interface. Use this instead of ref for accessing + * the public methods and properties of the component. + */ + componentRef?: IRefObject; +} + +/** + * {@docCategory DeclarativeChart} + */ +export interface IDeclarativeChart { + exportAsImage: (opts?: IImageExportOptions) => Promise; } const useColorMapping = () => { @@ -77,7 +88,9 @@ export const DeclarativeChart: React.FunctionComponent = const isXDate = isDateArray(xValues); const isXNumber = isNumberArray(xValues); const colorMap = useColorMapping(); - const isDarkTheme = UseIsDarkTheme(); + const theme = useTheme(); + const isDarkTheme = theme?.isInverted ?? false; + const chartRef = React.useRef(null); const [activeLegends, setActiveLegends] = React.useState(selectedLegends ?? []); const onActiveLegendsChange = (keys: string[]) => { @@ -93,12 +106,31 @@ export const DeclarativeChart: React.FunctionComponent = ...(activeLegends.length > 0 && { selectedLegend: activeLegends[0] }), }; + const exportAsImage = React.useCallback( + (opts?: IImageExportOptions) => { + return toImage(chartRef.current?.chartContainer, { + background: theme.palette.white, + ...opts, + }); + }, + [theme], + ); + + React.useImperativeHandle( + props.componentRef, + () => ({ + exportAsImage, + }), + [exportAsImage], + ); + switch (data[0].type) { case 'pie': return ( ); case 'bar': @@ -108,6 +140,7 @@ export const DeclarativeChart: React.FunctionComponent = ); } else { @@ -116,6 +149,7 @@ export const DeclarativeChart: React.FunctionComponent = ); } @@ -123,6 +157,7 @@ export const DeclarativeChart: React.FunctionComponent = ); } @@ -134,6 +169,7 @@ export const DeclarativeChart: React.FunctionComponent = ); } @@ -145,6 +181,7 @@ export const DeclarativeChart: React.FunctionComponent = canSelectMultipleLegends: true, selectedLegends: activeLegends, }} + componentRef={chartRef} /> ); } @@ -152,18 +189,31 @@ export const DeclarativeChart: React.FunctionComponent = ); case 'heatmap': - return ; + return ( + + ); case 'sankey': - return ; + return ( + + ); case 'indicator': if (data?.[0]?.mode?.includes('gauge')) { return ( ); } @@ -173,6 +223,7 @@ export const DeclarativeChart: React.FunctionComponent = ); default: diff --git a/packages/charts/react-charting/src/components/DeclarativeChart/imageExporter.ts b/packages/charts/react-charting/src/components/DeclarativeChart/imageExporter.ts new file mode 100644 index 00000000000000..a51400a5582a21 --- /dev/null +++ b/packages/charts/react-charting/src/components/DeclarativeChart/imageExporter.ts @@ -0,0 +1,266 @@ +import { create as d3Create, select as d3Select, Selection } from 'd3-selection'; + +/** + * {@docCategory DeclarativeChart} + */ +export interface IImageExportOptions { + width?: number; + height?: number; + scale?: number; + background?: string; +} + +export function toImage(chartContainer?: HTMLElement | null, opts: IImageExportOptions = {}): Promise { + return new Promise((resolve, reject) => { + if (!chartContainer) { + return reject(new Error('Chart container is not defined')); + } + + try { + const background = opts.background || 'white'; + const svg = toSVG(chartContainer, background); + + const svgData = new XMLSerializer().serializeToString(svg.node); + const svgDataUrl = 'data:image/svg+xml;base64,' + btoa(unescapePonyfill(encodeURIComponent(svgData))); + + svgToPng(svgDataUrl, { + width: opts.width || svg.width, + height: opts.height || svg.height, + scale: opts.scale, + }) + .then(resolve) + .catch(reject); + } catch (err) { + return reject(err); + } + }); +} + +function toSVG(chartContainer: HTMLElement, background: string) { + const svg = chartContainer.querySelector('svg'); + if (!svg) { + throw new Error('SVG not found'); + } + + const { width: svgWidth, height: svgHeight } = svg.getBoundingClientRect(); + const classNames = new Set(); + const legendGroup = cloneLegendsToSVG(chartContainer, svgWidth, svgHeight, classNames); + const w1 = Math.max(svgWidth, legendGroup.width); + const h1 = svgHeight + legendGroup.height; + const clonedSvg = d3Select(svg.cloneNode(true) as SVGSVGElement) + .attr('width', null) + .attr('height', null) + .attr('viewBox', null); + + if (legendGroup.node) { + 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 svgElements = svg.getElementsByTagName('*'); + const styleSheets = document.styleSheets; + const styleRules: string[] = []; + + for (let i = svgElements.length - 1; i--; ) { + svgElements[i].classList.forEach(className => { + classNames.add(`.${className}`); + }); + } + + for (let i = 0; i < styleSheets.length; i++) { + const rules = styleSheets[i].cssRules; + for (let j = 0; j < rules.length; j++) { + if (rules[j].constructor.name === 'CSSStyleRule') { + const selectorText = (rules[j] as CSSStyleRule).selectorText; + const hasClassName = selectorText.split(' ').some(word => classNames.has(word)); + + if (hasClassName) { + styleRules.push(rules[j].cssText); + } + } + } + } + + const xmlDocument = new DOMParser().parseFromString('', 'image/svg+xml'); + const styleNode = xmlDocument.createCDATASection(styleRules.join(' ')); + clonedSvg.insert('defs', ':first-child').append('style').attr('type', 'text/css').node()!.appendChild(styleNode); + + clonedSvg.attr('width', w1).attr('height', h1).attr('viewBox', `0 0 ${w1} ${h1}`); + + return { + node: clonedSvg.node()!, + width: w1, + height: h1, + }; +} + +function cloneLegendsToSVG(chartContainer: HTMLElement, svgWidth: number, svgHeight: number, classNames: Set) { + const legendButtons = chartContainer.querySelectorAll(` + button[class^="legend-"], + [class^="legendContainer-"] div[class^="overflowIndicationTextStyle-"], + [class^="legendsContainer-"] div[class^="overflowIndicationTextStyle-"] + `); + if (legendButtons.length === 0) { + return { + node: null, + width: 0, + height: 0, + }; + } + + const legendGroup = d3Create('svg:g'); + let legendX = 0; + let legendY = 8; + let legendLine: Selection[] = []; + const legendLines: (typeof legendLine)[] = []; + const legendLineWidths: number[] = []; + + for (let i = 0; i < legendButtons.length; i++) { + const { width: legendWidth } = legendButtons[i].getBoundingClientRect(); + const legendItem = legendGroup.append('g'); + + legendLine.push(legendItem); + if (legendX + legendWidth > svgWidth && legendLine.length > 1) { + legendLine.pop(); + legendLines.push(legendLine); + legendLineWidths.push(legendX); + + legendLine = [legendItem]; + legendX = 0; + legendY += 32; + } + + let legendText: HTMLDivElement | null; + let textOffset = 0; + + if (legendButtons[i].tagName.toLowerCase() === 'button') { + const legendRect = legendButtons[i].querySelector('[class^="rect"]'); + const { backgroundColor: legendColor, borderColor: legendBorderColor } = getComputedStyle(legendRect!); + + legendText = legendButtons[i].querySelector('[class^="text"]'); + legendText!.classList.forEach(className => classNames.add(`.${className}`)); + legendItem + .append('rect') + .attr('x', legendX + 8) + .attr('y', svgHeight + legendY + 8) + .attr('width', 12) + .attr('height', 12) + .attr('fill', legendColor) + .attr('stroke-width', 1) + .attr('stroke', legendBorderColor); + textOffset = 28; + } else { + legendText = legendButtons[i] as HTMLDivElement; + legendText.classList.forEach(className => classNames.add(`.${className}`)); + textOffset = 8; + } + + legendItem + .append('text') + .attr('x', legendX + textOffset) + .attr('y', svgHeight + legendY + 8) + .attr('dominant-baseline', 'hanging') + .attr('class', legendText!.getAttribute('class')) + .text(legendText!.textContent); + legendX += legendWidth; + } + + legendLines.push(legendLine); + legendLineWidths.push(legendX); + legendY += 32; + + const centerLegends = true; + if (centerLegends) { + legendLines.forEach((ln, idx) => { + const offsetX = Math.max((svgWidth - legendLineWidths[idx]) / 2, 0); + ln.forEach(item => { + item.attr('transform', `translate(${offsetX}, 0)`); + }); + }); + } + + return { + node: legendGroup.node(), + width: Math.max(...legendLineWidths), + height: legendY, + }; +} + +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; + + const canvas = document.createElement('canvas'); + const img = new Image(); + + canvas.width = w1; + canvas.height = h1; + + img.onload = function () { + const ctx = canvas.getContext('2d'); + if (!ctx) { + return reject(new Error('Canvas context is null')); + } + + ctx.clearRect(0, 0, w1, h1); + ctx.drawImage(img, 0, 0, w1, h1); + + const imgData = canvas.toDataURL('image/png'); + resolve(imgData); + }; + + img.onerror = function (err) { + reject(err); + }; + + img.src = svgDataUrl; + }); +} + +const hex2 = /^[\da-f]{2}$/i; +const hex4 = /^[\da-f]{4}$/i; + +/** + * A ponyfill for the deprecated `unescape` method, taken from the `core-js` library. + * + * Source: {@link https://github.com/zloirock/core-js/blob/167136f479d3b8519953f2e4c534ecdd1031d3cf/packages/core-js/modules/es.unescape.js core-js/packages/core-js/modules/es.unescape.js} + */ +function unescapePonyfill(str: string) { + let result = ''; + const length = str.length; + let index = 0; + let chr; + let part; + while (index < length) { + chr = str.charAt(index++); + if (chr === '%') { + if (str.charAt(index) === 'u') { + part = str.slice(index + 1, index + 5); + if (hex4.exec(part)) { + result += String.fromCharCode(parseInt(part, 16)); + index += 5; + continue; + } + } else { + part = str.slice(index, index + 2); + if (hex2.exec(part)) { + result += String.fromCharCode(parseInt(part, 16)); + index += 2; + continue; + } + } + } + result += chr; + } + return result; +} diff --git a/packages/charts/react-charting/src/components/DeclarativeChart/index.ts b/packages/charts/react-charting/src/components/DeclarativeChart/index.ts new file mode 100644 index 00000000000000..64d5b58644526d --- /dev/null +++ b/packages/charts/react-charting/src/components/DeclarativeChart/index.ts @@ -0,0 +1,2 @@ +export * from './DeclarativeChart'; +export type { IImageExportOptions } from './imageExporter'; 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 e531b39dd3fe72..ff04507c955bbf 100644 --- a/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx +++ b/packages/charts/react-charting/src/components/DonutChart/DonutChart.base.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { classNamesFunction, getId } from '@fluentui/react/lib/Utilities'; +import { classNamesFunction, getId, initializeComponentRef } from '@fluentui/react/lib/Utilities'; import { ScaleOrdinal } from 'd3-scale'; import { IProcessedStyleSet } from '@fluentui/react/lib/Styling'; import { Callout, DirectionalHint } from '@fluentui/react/lib/Callout'; @@ -9,6 +9,7 @@ import { Pie } from './Pie/index'; import { IChartDataPoint, IDonutChartProps, IDonutChartStyleProps, IDonutChartStyles } from './index'; import { getAccessibleDataObject, getColorFromToken, getNextColor, getNextGradient } from '../../utilities/index'; import { convertToLocaleString } from '../../utilities/locale-util'; +import { IChart } from '../../types/index'; const getClassNames = classNamesFunction(); const LEGEND_CONTAINER_HEIGHT = 40; @@ -29,7 +30,7 @@ export interface IDonutChartState { callOutAccessibilityData?: IAccessibilityProps; } -export class DonutChartBase extends React.Component { +export class DonutChartBase extends React.Component implements IChart { public static defaultProps: Partial = { innerRadius: 0, hideLabels: true, @@ -63,6 +64,9 @@ export class DonutChartBase extends React.Component { this.setState({ showHover: false, diff --git a/packages/charts/react-charting/src/components/DonutChart/DonutChart.types.ts b/packages/charts/react-charting/src/components/DonutChart/DonutChart.types.ts index ed1405d2aeca07..4adaaebb045af5 100644 --- a/packages/charts/react-charting/src/components/DonutChart/DonutChart.types.ts +++ b/packages/charts/react-charting/src/components/DonutChart/DonutChart.types.ts @@ -1,8 +1,8 @@ import { IStyle } from '@fluentui/react/lib/Styling'; -import { IRenderFunction, IStyleFunctionOrObject } from '@fluentui/react/lib/Utilities'; +import { IRefObject, IRenderFunction, IStyleFunctionOrObject } from '@fluentui/react/lib/Utilities'; import { ICartesianChartProps, ICartesianChartStyleProps } from '../CommonComponents/index'; import { ICalloutProps } from '@fluentui/react/lib/Callout'; -import { IChartProps, IChartDataPoint } from './index'; +import { IChartProps, IChartDataPoint, IChart } from './index'; export interface IDonutChart {} @@ -69,6 +69,12 @@ export interface IDonutChartProps extends ICartesianChartProps { * @default false */ roundCorners?: boolean; + + /** + * Optional callback to access the IChart interface. Use this instead of ref for accessing + * the public methods and properties of the component. + */ + componentRef?: IRefObject; } /** 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 87102b1ebacd3b..2636bb56f3a975 100644 --- a/packages/charts/react-charting/src/components/GaugeChart/GaugeChart.base.tsx +++ b/packages/charts/react-charting/src/components/GaugeChart/GaugeChart.base.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { arc as d3Arc } from 'd3-shape'; -import { classNamesFunction, getId, getRTL } from '@fluentui/react/lib/Utilities'; +import { classNamesFunction, getId, getRTL, initializeComponentRef } from '@fluentui/react/lib/Utilities'; import { IGaugeChartProps, IGaugeChartSegment, @@ -26,6 +26,7 @@ import { Callout, DirectionalHint } from '@fluentui/react/lib/Callout'; import { IYValueHover } from '../../index'; import { SVGTooltipText } from '../../utilities/SVGTooltipText'; import { select as d3Select } from 'd3-selection'; +import { IChart } from '../../types/index'; const GAUGE_MARGIN = 16; const LABEL_WIDTH = 36; @@ -120,7 +121,7 @@ export interface IExtendedSegment extends IGaugeChartSegment { end: number; } -export class GaugeChartBase extends React.Component { +export class GaugeChartBase extends React.Component implements IChart { private _classNames: IProcessedStyleSet; private _isRTL: boolean; private _innerRadius: number; @@ -136,6 +137,8 @@ export class GaugeChartBase extends React.Component { const { hideMinMax, chartTitle, sublabel } = this.props; diff --git a/packages/charts/react-charting/src/components/GaugeChart/GaugeChart.types.ts b/packages/charts/react-charting/src/components/GaugeChart/GaugeChart.types.ts index 648dc7abeca07d..9374a764dc3cad 100644 --- a/packages/charts/react-charting/src/components/GaugeChart/GaugeChart.types.ts +++ b/packages/charts/react-charting/src/components/GaugeChart/GaugeChart.types.ts @@ -1,7 +1,7 @@ import { IStyle, ITheme } from '@fluentui/react/lib/Styling'; -import { IStyleFunctionOrObject } from '@fluentui/react/lib/Utilities'; +import { IRefObject, IStyleFunctionOrObject } from '@fluentui/react/lib/Utilities'; import { ILegendsProps } from '../Legends/index'; -import { IAccessibilityProps } from '../../types/index'; +import { IAccessibilityProps, IChart } from '../../types/index'; import { ICalloutProps } from '@fluentui/react/lib/Callout'; /** @@ -168,6 +168,12 @@ export interface IGaugeChartProps { * @default false */ roundCorners?: boolean; + + /** + * Optional callback to access the IChart interface. Use this instead of ref for accessing + * the public methods and properties of the component. + */ + componentRef?: IRefObject; } /** 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 a35d3ff5b70651..3e3daa0f0731e5 100644 --- a/packages/charts/react-charting/src/components/GroupedVerticalBarChart/GroupedVerticalBarChart.base.tsx +++ b/packages/charts/react-charting/src/components/GroupedVerticalBarChart/GroupedVerticalBarChart.base.tsx @@ -3,7 +3,14 @@ import { max as d3Max } from 'd3-array'; import { select as d3Select } from 'd3-selection'; import { Axis as D3Axis } from 'd3-axis'; import { scaleBand as d3ScaleBand, scaleLinear as d3ScaleLinear } from 'd3-scale'; -import { classNamesFunction, getId, getRTL, memoizeFunction, warnDeprecations } from '@fluentui/react/lib/Utilities'; +import { + classNamesFunction, + getId, + getRTL, + initializeComponentRef, + memoizeFunction, + warnDeprecations, +} from '@fluentui/react/lib/Utilities'; import { IProcessedStyleSet, IPalette } from '@fluentui/react/lib/Styling'; import { DirectionalHint } from '@fluentui/react/lib/Callout'; import { FocusZoneDirection } from '@fluentui/react-focus'; @@ -39,6 +46,7 @@ import { IRefArrayData, Legends, } from '../../index'; +import { IChart } from '../../types/index'; const COMPONENT_NAME = 'GROUPED VERTICAL BAR CHART'; const getClassNames = classNamesFunction(); @@ -67,10 +75,10 @@ export interface IGroupedVerticalBarChartState extends IBasestate { calloutLegend: string; } -export class GroupedVerticalBarChartBase extends React.Component< - IGroupedVerticalBarChartProps, - IGroupedVerticalBarChartState -> { +export class GroupedVerticalBarChartBase + extends React.Component + implements IChart +{ public static defaultProps: Partial = { maxBarWidth: 24, }; @@ -100,9 +108,13 @@ export class GroupedVerticalBarChartBase extends React.Component< private _groupWidth: number; private _xAxisInnerPadding: number; private _xAxisOuterPadding: number; + private _cartesianChartRef: React.RefObject; public constructor(props: IGroupedVerticalBarChartProps) { super(props); + + initializeComponentRef(this); + this._createSet = memoizeFunction((data: IGroupedVerticalBarChartData[]) => this._createDataSetOfGVBC(data)); this.state = { color: '', @@ -129,6 +141,7 @@ export class GroupedVerticalBarChartBase extends React.Component< this._tooltipId = getId('GVBCTooltipId_'); this._emptyChartId = getId('_GVBC_empty'); this._domainMargin = MIN_DOMAIN_MARGIN; + this._cartesianChartRef = React.createRef(); } public render(): React.ReactNode { @@ -203,6 +216,7 @@ export class GroupedVerticalBarChartBase extends React.Component< xAxisOuterPadding: this._xAxisOuterPadding, })} barwidth={this._barWidth} + ref={this._cartesianChartRef} /* eslint-disable react/jsx-no-bind */ children={() => { return {this._groupedVerticalBarGraph}; @@ -218,6 +232,10 @@ export class GroupedVerticalBarChartBase extends React.Component< ); } + public get chartContainer(): HTMLElement | null { + return this._cartesianChartRef.current?.chartContainer || null; + } + private _getMinMaxOfYAxis = () => { return { startValue: 0, endValue: 0 }; }; 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 4aa3d4e2a2377d..5a967214ceefc2 100644 --- a/packages/charts/react-charting/src/components/HeatMapChart/HeatMapChart.base.tsx +++ b/packages/charts/react-charting/src/components/HeatMapChart/HeatMapChart.base.tsx @@ -1,7 +1,7 @@ import { CartesianChart, IChildProps, IModifiedCartesianChartProps } from '../../components/CommonComponents/index'; -import { IAccessibilityProps, IHeatMapChartData, IHeatMapChartDataPoint } from '../../types/IDataPoint'; +import { IAccessibilityProps, IChart, IHeatMapChartData, IHeatMapChartDataPoint } from '../../types/IDataPoint'; import { scaleLinear as d3ScaleLinear } from 'd3-scale'; -import { classNamesFunction, getId, memoizeFunction } from '@fluentui/react/lib/Utilities'; +import { classNamesFunction, getId, initializeComponentRef, memoizeFunction } from '@fluentui/react/lib/Utilities'; import { FocusZoneDirection } from '@fluentui/react-focus'; import { DirectionalHint } from '@fluentui/react/lib/Callout'; import { IProcessedStyleSet } from '@fluentui/react/lib/Styling'; @@ -87,7 +87,7 @@ export interface IHeatMapChartState { callOutAccessibilityData?: IAccessibilityProps; } const getClassNames = classNamesFunction(); -export class HeatMapChartBase extends React.Component { +export class HeatMapChartBase extends React.Component implements IChart { private _classNames: IProcessedStyleSet; private _stringXAxisDataPoints: string[]; private _stringYAxisDataPoints: string[]; @@ -114,8 +114,13 @@ export class HeatMapChartBase extends React.Component; + public constructor(props: IHeatMapChartProps) { super(props); + + initializeComponentRef(this); + /** * below funciton creates a new data set from the prop * @data and also finds all the unique x-axis datapoints @@ -145,6 +150,7 @@ export class HeatMapChartBase extends React.Component { @@ -231,6 +238,10 @@ export class HeatMapChartBase extends React.Component { return { startValue: 0, endValue: 0 }; }; 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 8cb522e55edd42..c84654b03726cc 100644 --- a/packages/charts/react-charting/src/components/HorizontalBarChartWithAxis/HorizontalBarChartWithAxis.base.tsx +++ b/packages/charts/react-charting/src/components/HorizontalBarChartWithAxis/HorizontalBarChartWithAxis.base.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { max as d3Max } from 'd3-array'; import { select as d3Select } from 'd3-selection'; import { scaleLinear as d3ScaleLinear, ScaleLinear as D3ScaleLinear, scaleBand as d3ScaleBand } from 'd3-scale'; -import { classNamesFunction, getId, getRTL } from '@fluentui/react/lib/Utilities'; +import { classNamesFunction, getId, getRTL, initializeComponentRef } from '@fluentui/react/lib/Utilities'; import { IProcessedStyleSet, IPalette } from '@fluentui/react/lib/Styling'; import { DirectionalHint } from '@fluentui/react/lib/Callout'; import { ILegend } from '../../components/Legends/Legends.types'; @@ -13,6 +13,7 @@ import { IHorizontalBarChartWithAxisDataPoint, IRefArrayData, IMargins, + IChart, } from '../../types/IDataPoint'; import { IChildProps, IYValueHover } from '../CommonComponents/CartesianChart.types'; import { CartesianChart } from '../CommonComponents/CartesianChart'; @@ -58,10 +59,10 @@ export interface IHorizontalBarChartWithAxisState extends IBasestate { type ColorScale = (_p?: number) => string; -export class HorizontalBarChartWithAxisBase extends React.Component< - IHorizontalBarChartWithAxisProps, - IHorizontalBarChartWithAxisState -> { +export class HorizontalBarChartWithAxisBase + extends React.Component + implements IChart +{ private _points: IHorizontalBarChartWithAxisDataPoint[]; private _barHeight: number; private _colors: string[]; @@ -77,9 +78,13 @@ export class HorizontalBarChartWithAxisBase extends React.Component< private _xAxisType: XAxisTypes; private _yAxisType: YAxisType; private _calloutAnchorPoint: IHorizontalBarChartWithAxisDataPoint | null; + private _cartesianChartRef: React.RefObject; public constructor(props: IHorizontalBarChartWithAxisProps) { super(props); + + initializeComponentRef(this); + this.state = { color: '', dataForHoverCard: 0, @@ -105,6 +110,7 @@ export class HorizontalBarChartWithAxisBase extends React.Component< this.props.data! && this.props.data!.length > 0 ? (getTypeOfAxis(this.props.data![0].y, false) as YAxisType) : YAxisType.StringAxis; + this._cartesianChartRef = React.createRef(); } public render(): JSX.Element { @@ -163,6 +169,7 @@ export class HorizontalBarChartWithAxisBase extends React.Component< getGraphData={this._getGraphData} getAxisData={this._getAxisData} onChartMouseLeave={this._handleChartMouseLeave} + ref={this._cartesianChartRef} /* eslint-disable react/jsx-no-bind */ children={(props: IChildProps) => { return ( @@ -175,6 +182,10 @@ export class HorizontalBarChartWithAxisBase extends React.Component< ); } + public get chartContainer(): HTMLElement | null { + return this._cartesianChartRef.current?.chartContainer || null; + } + private _getDomainNRangeValues = ( points: IHorizontalBarChartWithAxisDataPoint[], margins: IMargins, 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 55c0370d590761..c0dcab6b52dc41 100644 --- a/packages/charts/react-charting/src/components/LineChart/LineChart.base.tsx +++ b/packages/charts/react-charting/src/components/LineChart/LineChart.base.tsx @@ -4,7 +4,14 @@ import { select as d3Select, pointer } from 'd3-selection'; import { bisector } from 'd3-array'; import { ILegend, Legends } from '../Legends/index'; import { line as d3Line, curveLinear as d3curveLinear } from 'd3-shape'; -import { classNamesFunction, getId, find, memoizeFunction, getRTL } from '@fluentui/react/lib/Utilities'; +import { + classNamesFunction, + getId, + find, + memoizeFunction, + getRTL, + initializeComponentRef, +} from '@fluentui/react/lib/Utilities'; import { IAccessibilityProps, CartesianChart, @@ -42,6 +49,7 @@ import { createStringYAxis, formatDate, } from '../../utilities/index'; +import { IChart } from '../../types/index'; type NumericAxis = D3Axis; const getClassNames = classNamesFunction(); @@ -146,7 +154,7 @@ export interface ILineChartState extends IBasestate { activeLine: number | null; } -export class LineChartBase extends React.Component { +export class LineChartBase extends React.Component implements IChart { public static defaultProps: Partial = { enableReflow: true, useUTC: true, @@ -178,9 +186,13 @@ export class LineChartBase extends React.Component; constructor(props: ILineChartProps) { super(props); + + initializeComponentRef(this); + this.state = { hoverXValue: '', activeLegend: '', @@ -210,6 +222,7 @@ export class LineChartBase extends React.Component this._createLegends(data)); this._firstRenderOptimization = true; this._emptyChartId = getId('_LineChart_empty'); + this._cartesianChartRef = React.createRef(); props.eventAnnotationProps && props.eventAnnotationProps.labelHeight && @@ -293,6 +306,7 @@ export class LineChartBase extends React.Component { @@ -349,6 +363,10 @@ export class LineChartBase extends React.Component { +export class SankeyChartBase extends React.Component implements IChart { public static defaultProps: Partial = { enableReflow: true, }; - private chartContainer: HTMLDivElement; + public chartContainer: HTMLDivElement; private _reqID: number; private readonly _calloutId: string; private readonly _linkId: string; @@ -628,6 +629,9 @@ export class SankeyChartBase extends React.Component; } /** diff --git a/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx b/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx index 9e793a25b87c60..0132072838762c 100644 --- a/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx +++ b/packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx @@ -9,7 +9,7 @@ import { scaleUtc as d3ScaleUtc, scaleTime as d3ScaleTime, } from 'd3-scale'; -import { classNamesFunction, getId, getRTL } from '@fluentui/react/lib/Utilities'; +import { classNamesFunction, getId, getRTL, initializeComponentRef } from '@fluentui/react/lib/Utilities'; import { IProcessedStyleSet, IPalette } from '@fluentui/react/lib/Styling'; import { DirectionalHint } from '@fluentui/react/lib/Callout'; import { @@ -53,6 +53,7 @@ import { formatDate, getNextGradient, } from '../../utilities/index'; +import { IChart } from '../../types/index'; enum CircleVisbility { show = 'visibility', @@ -76,7 +77,10 @@ export interface IVerticalBarChartState extends IBasestate { type ColorScale = (_p?: number) => string; -export class VerticalBarChartBase extends React.Component { +export class VerticalBarChartBase + extends React.Component + implements IChart +{ public static defaultProps: Partial = { maxBarWidth: 24, useUTC: true, @@ -102,9 +106,13 @@ export class VerticalBarChartBase extends React.Component; public constructor(props: IVerticalBarChartProps) { super(props); + + initializeComponentRef(this); + this.state = { color: '', dataForHoverCard: 0, @@ -129,6 +137,7 @@ export class VerticalBarChartBase extends React.Component { return ( @@ -230,6 +240,10 @@ export class VerticalBarChartBase extends React.Component(); type NumericAxis = D3Axis; @@ -94,10 +102,10 @@ export interface IVerticalStackedBarChartState extends IBasestate { callOutAccessibilityData?: IAccessibilityProps; calloutLegend: string; } -export class VerticalStackedBarChartBase extends React.Component< - IVerticalStackedBarChartProps, - IVerticalStackedBarChartState -> { +export class VerticalStackedBarChartBase + extends React.Component + implements IChart +{ public static defaultProps: Partial = { maxBarWidth: 24, useUTC: true, @@ -123,9 +131,13 @@ export class VerticalStackedBarChartBase extends React.Component< private _emptyChartId: string; private _xAxisInnerPadding: number; private _xAxisOuterPadding: number; + private _cartesianChartRef: React.RefObject; public constructor(props: IVerticalStackedBarChartProps) { super(props); + + initializeComponentRef(this); + this.state = { isCalloutVisible: false, selectedLegend: props.legendProps?.selectedLegend ?? '', @@ -154,6 +166,7 @@ export class VerticalStackedBarChartBase extends React.Component< this._createLegendsForLine = memoizeFunction((data: IVerticalStackedChartProps[]) => this._getLineLegends(data)); this._emptyChartId = getId('_VSBC_empty'); this._domainMargin = MIN_DOMAIN_MARGIN; + this._cartesianChartRef = React.createRef(); } public componentDidUpdate(prevProps: IVerticalStackedBarChartProps): void { @@ -238,6 +251,7 @@ export class VerticalStackedBarChartBase extends React.Component< xAxisInnerPadding: this._xAxisInnerPadding, xAxisOuterPadding: this._xAxisOuterPadding, })} + ref={this._cartesianChartRef} /* eslint-disable react/jsx-no-bind */ children={(props: IChildProps) => { return ( @@ -269,6 +283,10 @@ export class VerticalStackedBarChartBase extends React.Component< ); } + public get chartContainer(): HTMLElement | null { + return this._cartesianChartRef.current?.chartContainer || null; + } + /** * This function tells us what to focus either the whole stack as focusable item. * or each individual item in the stack as focusable item. basically it depends diff --git a/packages/charts/react-charting/src/index.ts b/packages/charts/react-charting/src/index.ts index c27c09d1430456..2ae3660ac7f75a 100644 --- a/packages/charts/react-charting/src/index.ts +++ b/packages/charts/react-charting/src/index.ts @@ -97,6 +97,7 @@ export type { IVerticalStackedChartProps, SLink, SNode, + IChart, } from './types/index'; export type { IChartHoverCardProps, @@ -135,7 +136,7 @@ export { DataVizPalette, getColorFromToken, getNextColor } from './utilities/col export { DataVizGradientPalette, getGradientFromToken, getNextGradient } from './utilities/gradients'; export type { IGaugeChartProps, IGaugeChartSegment, IGaugeChartStyleProps, IGaugeChartStyles } from './GaugeChart'; export { GaugeChart, GaugeChartVariant, GaugeValueFormat } from './GaugeChart'; -export type { DeclarativeChartProps, Schema } from './DeclarativeChart'; +export type { DeclarativeChartProps, Schema, IDeclarativeChart, IImageExportOptions } from './DeclarativeChart'; export { DeclarativeChart } from './DeclarativeChart'; import './version'; diff --git a/packages/charts/react-charting/src/types/IDataPoint.ts b/packages/charts/react-charting/src/types/IDataPoint.ts index 7e4127b26f2ee5..dda593a408f4f5 100644 --- a/packages/charts/react-charting/src/types/IDataPoint.ts +++ b/packages/charts/react-charting/src/types/IDataPoint.ts @@ -814,3 +814,10 @@ export interface ICustomizedCalloutData { x: number | string | Date; values: ICustomizedCalloutDataPoint[]; } + +/** + * {@docCategory Chart} + */ +export interface IChart { + chartContainer: HTMLElement | null; +} diff --git a/packages/react-examples/src/react-charting/DeclarativeChart/DeclarativeChart.Basic.Example.tsx b/packages/react-examples/src/react-charting/DeclarativeChart/DeclarativeChart.Basic.Example.tsx index e1e95218a39a98..ec0d58eabee23c 100644 --- a/packages/react-examples/src/react-charting/DeclarativeChart/DeclarativeChart.Basic.Example.tsx +++ b/packages/react-examples/src/react-charting/DeclarativeChart/DeclarativeChart.Basic.Example.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Dropdown, IDropdownOption } from '@fluentui/react/lib/Dropdown'; import { Toggle } from '@fluentui/react/lib/Toggle'; -import { DeclarativeChart, DeclarativeChartProps, Schema } from '@fluentui/react-charting'; +import { DeclarativeChart, DeclarativeChartProps, IDeclarativeChart, Schema } from '@fluentui/react-charting'; interface IDeclarativeChartState { selectedChoice: string; @@ -37,7 +37,18 @@ const schemas: any[] = [ const dropdownStyles = { dropdown: { width: 200 } }; +function fileSaver(url: string) { + const saveLink = document.createElement('a'); + saveLink.href = url; + saveLink.download = 'converted-image.png'; + document.body.appendChild(saveLink); + saveLink.click(); + document.body.removeChild(saveLink); +} + export class DeclarativeChartBasicExample extends React.Component<{}, IDeclarativeChartState> { + private _declarativeChartRef: React.RefObject; + constructor(props: DeclarativeChartProps) { super(props); this.state = { @@ -45,6 +56,8 @@ export class DeclarativeChartBasicExample extends React.Component<{}, IDeclarati preSelectLegends: false, selectedLegends: '', }; + + this._declarativeChartRef = React.createRef(); } public render(): JSX.Element { @@ -99,8 +112,22 @@ export class DeclarativeChartBasicExample extends React.Component<{}, IDeclarati />
+
- +
Legend selection changed : {this.state.selectedLegends}