diff --git a/apps/public-docsite-v9/package.json b/apps/public-docsite-v9/package.json index 49db0a8bf7f97..eeedba827fb46 100644 --- a/apps/public-docsite-v9/package.json +++ b/apps/public-docsite-v9/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@fluentui/react-calendar-compat": "*", + "@fluentui/react-charts-preview": "*", "@fluentui/react-datepicker-compat": "*", "@fluentui/react-migration-v8-v9": "*", "@fluentui/react-migration-v0-v9": "*", diff --git a/apps/vr-tests-react-components/package.json b/apps/vr-tests-react-components/package.json index 555564a0c7ba7..555ce58bd2368 100644 --- a/apps/vr-tests-react-components/package.json +++ b/apps/vr-tests-react-components/package.json @@ -25,6 +25,7 @@ "@fluentui/react-button": "*", "@fluentui/react-calendar-compat": "*", "@fluentui/react-card": "*", + "@fluentui/react-charts-preview": "*", "@fluentui/react-checkbox": "*", "@fluentui/react-combobox": "*", "@fluentui/react-context-selector": "*", diff --git a/apps/vr-tests-react-components/src/stories/Charts/DonutChart.stories.tsx b/apps/vr-tests-react-components/src/stories/Charts/DonutChart.stories.tsx new file mode 100644 index 0000000000000..d8f99d1ea3d3d --- /dev/null +++ b/apps/vr-tests-react-components/src/stories/Charts/DonutChart.stories.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import type { Meta } from '@storybook/react'; +import { DARK_MODE, getStoryVariant, RTL, TestWrapperDecorator } from '../../utilities'; +import { Steps, StoryWright } from 'storywright'; +import { ChartProps, ChartDataPoint, DonutChart } from '@fluentui/react-charts-preview'; + +export default { + title: 'Charts/DonutChart', + + decorators: [ + (story, context) => TestWrapperDecorator(story, context), + (story, context) => { + const steps = new Steps().snapshot('default', { cropTo: '.testWrapper' }).end(); + return {story(context)}; + }, + ], +} satisfies Meta; + +export const Basic = () => { + const points: ChartDataPoint[] = [ + { legend: 'first', data: 20000, color: '#DADADA', xAxisCalloutData: '2020/04/30' }, + { legend: 'second', data: 39000, color: '#0078D4', xAxisCalloutData: '2020/04/20' }, + ]; + + const data: ChartProps = { + chartTitle: 'Donut chart basic example', + chartData: points, + }; + return ( +
+ +
+ ); +}; + +export const BasicDarkMode = getStoryVariant(Basic, DARK_MODE); + +export const BasicRTL = getStoryVariant(Basic, RTL); + +export const Dynamic = () => { + const data: ChartProps = { + chartTitle: 'Donut chart dynamic example', + chartData: [ + { + legend: 'first', + data: Math.floor(120), + color: '#00bcf2', + }, + { + legend: 'second', + data: Math.floor(130), + color: '#b4a0ff', + }, + { + legend: 'third', + data: Math.floor(10), + color: '#fff100', + }, + { + legend: 'fourth', + data: Math.floor(270), + color: '#605e5c', + }, + ], + }; + + return ( +
+ +
+ ); +}; + +export const DynamicDarkMode = getStoryVariant(Dynamic, DARK_MODE); + +export const DynamicRTL = getStoryVariant(Dynamic, RTL); diff --git a/apps/vr-tests-react-components/src/stories/Charts/HorizontalBarChart.stories.tsx b/apps/vr-tests-react-components/src/stories/Charts/HorizontalBarChart.stories.tsx new file mode 100644 index 0000000000000..be0c4ff1e088b --- /dev/null +++ b/apps/vr-tests-react-components/src/stories/Charts/HorizontalBarChart.stories.tsx @@ -0,0 +1,286 @@ +import * as React from 'react'; +import type { Meta } from '@storybook/react'; +import { DARK_MODE, getStoryVariant, RTL, TestWrapperDecorator } from '../../utilities'; +import { Steps, StoryWright } from 'storywright'; +import { ChartProps, HorizontalBarChart, HorizontalBarChartVariant } from '@fluentui/react-charts-preview'; + +export default { + title: 'Charts/HorizontalBarChart', + + decorators: [ + (story, context) => TestWrapperDecorator(story, context), + (story, context) => { + const steps = + context.name.includes('Basic') && !context.name.includes('RTL') + ? new Steps() + .snapshot('default', { cropTo: '.testWrapper' }) + .executeScript( + // eslint-disable-next-line @fluentui/max-len + `document.querySelectorAll('g[id^="_HorizontalLine"]')[2].children[0].dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true }));`, + ) + .snapshot('hover', { cropTo: '.testWrapper' }) + .end() + : new Steps().snapshot('default', { cropTo: '.testWrapper' }).end(); + return {story(context)}; + }, + ], +} satisfies Meta; + +export const Basic = () => { + const hideRatio: boolean[] = [true, false]; + const data: ChartProps[] = [ + { + chartTitle: 'one', + chartData: [ + { + legend: 'one', + horizontalBarChartdata: { x: 1543, y: 15000 }, + color: '#4cb4b7', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '10%', + }, + ], + }, + { + chartTitle: 'two', + chartData: [ + { + legend: 'two', + horizontalBarChartdata: { x: 800, y: 15000 }, + color: '#800080', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '5%', + }, + ], + }, + { + chartTitle: 'three', + chartData: [ + { + legend: 'three', + horizontalBarChartdata: { x: 8888, y: 15000 }, + color: '#ff0000', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '59%', + }, + ], + }, + { + chartTitle: 'four', + chartData: [ + { + legend: 'four', + horizontalBarChartdata: { x: 15888, y: 15000 }, + color: '#fbc0c3', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '105%', + }, + ], + }, + { + chartTitle: 'five', + chartData: [ + { + legend: 'five', + horizontalBarChartdata: { x: 11444, y: 15000 }, + color: '#f7630c', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '76%', + }, + ], + }, + { + chartTitle: 'six', + chartData: [ + { + legend: 'six', + horizontalBarChartdata: { x: 14000, y: 15000 }, + color: '#107c10', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '93%', + }, + ], + }, + { + chartTitle: 'seven', + chartData: [ + { + legend: 'seven', + horizontalBarChartdata: { x: 9855, y: 15000 }, + color: '#6e0811', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '65%', + }, + ], + }, + { + chartTitle: 'eight', + chartData: [ + { + legend: 'eight', + horizontalBarChartdata: { x: 4250, y: 15000 }, + color: '#3a96dd', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '28%', + }, + ], + }, + ]; + + return ( +
+ +
+ ); +}; + +export const BasicDarkMode = getStoryVariant(Basic, DARK_MODE); + +export const BasicRTL = getStoryVariant(Basic, RTL); + +export const WithBenchmark = () => { + const hideRatio: boolean[] = [true, false]; + + const data: ChartProps[] = [ + { + chartTitle: 'one', + chartData: [ + { + legend: 'one', + data: 50, + horizontalBarChartdata: { x: 10, y: 100 }, + color: '#4cb4b7', + }, + ], + }, + { + chartTitle: 'two', + chartData: [ + { + legend: 'two', + data: 30, + horizontalBarChartdata: { x: 30, y: 200 }, + color: '#800080', + }, + ], + }, + { + chartTitle: 'three', + chartData: [ + { + legend: 'three', + data: 5, + horizontalBarChartdata: { x: 15, y: 50 }, + color: '#ff0000', + }, + ], + }, + ]; + + return ( +
+ +
+ ); +}; + +WithBenchmark.storyName = 'With_Benchmark'; + +export const WithBenchmarkDarkMode = getStoryVariant(WithBenchmark, DARK_MODE); + +export const WithBenchmarkRTL = getStoryVariant(WithBenchmark, RTL); + +export const Variant = () => { + const data: ChartProps[] = [ + { + chartTitle: 'one', + chartData: [ + { + legend: 'one', + horizontalBarChartdata: { x: 1543, y: 15000 }, + color: '#4cb4b7', + }, + ], + }, + { + chartTitle: 'two', + chartData: [ + { + legend: 'two', + horizontalBarChartdata: { x: 800, y: 15000 }, + color: '#800080', + }, + ], + }, + { + chartTitle: 'three', + chartData: [ + { + legend: 'three', + horizontalBarChartdata: { x: 8888, y: 15000 }, + color: '#ff0000', + }, + ], + }, + { + chartTitle: 'four', + chartData: [ + { + legend: 'four', + horizontalBarChartdata: { x: 15888, y: 15000 }, + color: '#fbc0c3', + }, + ], + }, + { + chartTitle: 'five', + chartData: [ + { + legend: 'five', + horizontalBarChartdata: { x: 11444, y: 15000 }, + color: '#f7630c', + }, + ], + }, + { + chartTitle: 'six', + chartData: [ + { + legend: 'six', + horizontalBarChartdata: { x: 14000, y: 15000 }, + color: '#107c10', + }, + ], + }, + { + chartTitle: 'seven', + chartData: [ + { + legend: 'seven', + horizontalBarChartdata: { x: 9855, y: 15000 }, + color: '#6e0811', + }, + ], + }, + { + chartTitle: 'eight', + chartData: [ + { + legend: 'eight', + horizontalBarChartdata: { x: 4250, y: 15000 }, + color: '#3a96dd', + }, + ], + }, + ]; + + return ( +
+ +
+ ); +}; + +export const VariantDarkMode = getStoryVariant(Variant, DARK_MODE); + +export const VariantRTL = getStoryVariant(Variant, RTL); diff --git a/apps/vr-tests-react-components/src/stories/Charts/Legend.stories.tsx b/apps/vr-tests-react-components/src/stories/Charts/Legend.stories.tsx new file mode 100644 index 0000000000000..5e9917968536a --- /dev/null +++ b/apps/vr-tests-react-components/src/stories/Charts/Legend.stories.tsx @@ -0,0 +1,291 @@ +import * as React from 'react'; +import type { Meta } from '@storybook/react'; +import { DARK_MODE, getStoryVariant, RTL, TestWrapperDecorator } from '../../utilities'; +import { Steps, StoryWright } from 'storywright'; +import { Legend, Legends } from '@fluentui/react-charts-preview'; + +export default { + title: 'Charts/Legend', + + decorators: [ + (story, context) => TestWrapperDecorator(story, context), + (story, context) => { + const steps = context.name.includes('Overflow') + ? new Steps() + .snapshot('default', { cropTo: '.testWrapper' }) + .executeScript(`document.querySelectorAll('div[class^="overflowIndicationTextStyle"]')[0].click()`) + .snapshot('expanded', { cropTo: '.testWrapper' }) + .end() + : new Steps().snapshot('default', { cropTo: '.testWrapper' }).end(); + return {story()}; + }, + ], +} satisfies Meta; + +export const Basic = () => { + const legends: Legend[] = [ + { + title: 'fsd 1', + color: '#0078d4', + action: () => { + console.log('click from LegendsPage'); + alert('Legend1 clicked'); + }, + onMouseOutAction: () => { + console.log('On mouse out action'); + }, + hoverAction: () => { + console.log('hover action'); + }, + }, + { + title: 'Legend 2', + color: '#e81123', + action: () => { + alert('Legend2 clicked'); + }, + hoverAction: () => { + console.log('hover action'); + }, + }, + { + title: 'Legend 3', + color: '#107c10', + action: () => { + alert('Legend3 clicked'); + }, + hoverAction: () => { + console.log('hover action'); + }, + shape: 'diamond', + }, + { + title: 'Legend 4', + color: '#ffb900', + shape: 'triangle', + action: () => { + alert('Legend4 clicked'); + }, + hoverAction: () => { + console.log('hover action'); + }, + }, + ]; + + return ( +
+ +
+ ); +}; + +export const BasicDarkMode = getStoryVariant(Basic, DARK_MODE); + +export const BasicRTL = getStoryVariant(Basic, RTL); + +export const Overflow = () => { + const legends: Legend[] = [ + { + title: 'Legend 1', + color: '#e81123', + action: () => { + console.log('Legend1 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend1'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 2', + color: '#107c10', + action: () => { + console.log('Legend2 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend2'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 3', + color: '#ffb900', + action: () => { + console.log('Legend3 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend3'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 4', + color: '#0078d4', + action: () => { + console.log('Legend4 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend4'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 5', + color: '#b4a0ff', + action: () => { + console.log('Legend5 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend5'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 6', + color: '#ea4300', + action: () => { + console.log('Legend6 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend6'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 7', + color: '#b4009e', + action: () => { + console.log('Legend7 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend7'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 8', + color: '#005a9e', + action: () => { + console.log('Legend8 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend8'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 9', + color: '#a4262c', + action: () => { + console.log('Legend9 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend9'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 10', + color: '#00188f', + action: () => { + console.log('Legend10 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend10'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 11', + color: 'rgba(0,0,0,.4)', + action: () => { + console.log('Legend11 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend11'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 12', + color: '#004b1c', + action: () => { + console.log('Legend12 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend12'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 13', + color: '#fff100', + action: () => { + console.log('Legend13 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend13'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 14', + color: '#e3008c', + action: () => { + console.log('Legend14 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend14'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 15', + color: '#32145a', + action: () => { + console.log('Legend15 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend15'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 16', + color: '#00188f', + action: () => { + console.log('Legend16 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend16'); + }, + onMouseOutAction: () => undefined, + }, + { + title: 'Legend 17', + color: '#0078d4', + action: () => { + console.log('Legend17 clicked'); + }, + hoverAction: () => { + console.log('Hover action for legend17'); + }, + onMouseOutAction: () => undefined, + }, + ]; + + return ( +
+ +
+ ); +}; + +export const OverflowDarkMode = getStoryVariant(Overflow, DARK_MODE); + +export const OverflowRTL = getStoryVariant(Overflow, RTL); diff --git a/apps/vr-tests-react-components/src/stories/Charts/LineChart.stories.tsx b/apps/vr-tests-react-components/src/stories/Charts/LineChart.stories.tsx new file mode 100644 index 0000000000000..13736f225af00 --- /dev/null +++ b/apps/vr-tests-react-components/src/stories/Charts/LineChart.stories.tsx @@ -0,0 +1,833 @@ +import * as React from 'react'; +import type { Meta } from '@storybook/react'; +import { Steps, StoryWright } from 'storywright'; +import { DARK_MODE, getStoryVariant, RTL, TestWrapperDecorator } from '../../utilities'; +import { + LineChartPoints, + LineChart, + ChartProps, + DataVizPalette, + CustomizedCalloutData, +} from '@fluentui/react-charts-preview'; + +export default { + title: 'Charts/LineChart', + + decorators: [ + TestWrapperDecorator, + (story, context) => { + const steps = + context.name.startsWith('Basic') && !context.name.includes('RTL') + ? new Steps() + .snapshot('default', { cropTo: '.testWrapper' }) + // Selector to select a point on the line, to capture the callout + .executeScript( + // eslint-disable-next-line @fluentui/max-len + `document.querySelectorAll('line[id^="line"]')[3].dispatchEvent(new MouseEvent('mouseover',{bubbles: true,cancelable: true}))`, + ) + .snapshot('hover', { cropTo: '.testWrapper' }) + .end() + : new Steps().snapshot('default', { cropTo: '.testWrapper' }).end(); + + return {story()}; + }, + ], +} satisfies Meta; + +export const Basic = () => { + const margins = { left: 35, top: 20, bottom: 35, right: 20 }; + const data: ChartProps = { + chartTitle: 'Line Chart', + lineChartData: [ + { + legend: 'From_Legacy_to_O365', + data: [ + { + x: new Date('2020-03-03T00:00:00.000Z'), + y: 216000, + onDataPointClick: () => alert('click on 217000'), + }, + { + x: new Date('2020-03-03T10:00:00.000Z'), + y: 218123, + onDataPointClick: () => alert('click on 217123'), + }, + { + x: new Date('2020-03-03T11:00:00.000Z'), + y: 217124, + onDataPointClick: () => alert('click on 217124'), + }, + { + x: new Date('2020-03-04T00:00:00.000Z'), + y: 248000, + onDataPointClick: () => alert('click on 248000'), + }, + { + x: new Date('2020-03-05T00:00:00.000Z'), + y: 252000, + onDataPointClick: () => alert('click on 252000'), + }, + { + x: new Date('2020-03-06T00:00:00.000Z'), + y: 274000, + onDataPointClick: () => alert('click on 274000'), + }, + { + x: new Date('2020-03-07T00:00:00.000Z'), + y: 260000, + onDataPointClick: () => alert('click on 260000'), + }, + { + x: new Date('2020-03-08T00:00:00.000Z'), + y: 304000, + onDataPointClick: () => alert('click on 300000'), + }, + { + x: new Date('2020-03-09T00:00:00.000Z'), + y: 218000, + onDataPointClick: () => alert('click on 218000'), + }, + ], + color: '#2c72a8', + lineOptions: { + lineBorderWidth: '4', + }, + onLineClick: () => console.log('From_Legacy_to_O365'), + }, + { + legend: 'All', + data: [ + { + x: new Date('2020-03-03T00:00:00.000Z'), + y: 297000, + }, + { + x: new Date('2020-03-04T00:00:00.000Z'), + y: 284000, + }, + { + x: new Date('2020-03-05T00:00:00.000Z'), + y: 282000, + }, + { + x: new Date('2020-03-06T00:00:00.000Z'), + y: 294000, + }, + { + x: new Date('2020-03-07T00:00:00.000Z'), + y: 224000, + }, + { + x: new Date('2020-03-08T00:00:00.000Z'), + y: 300000, + }, + { + x: new Date('2020-03-09T00:00:00.000Z'), + y: 298000, + }, + ], + color: '#13a10e', + lineOptions: { + lineBorderWidth: '4', + }, + }, + { + legend: 'single point', + data: [ + { + x: new Date('2020-03-05T00:00:00.000Z'), + y: 282000, + }, + ], + color: '#ffff00', + }, + ], + }; + + const rootStyle = { width: `700px`, height: `300px` }; + + return ( +
+ +
+ ); +}; + +export const BasicRTL = getStoryVariant(Basic, RTL); + +export const BasicDarkMode = getStoryVariant(Basic, DARK_MODE); + +export const Events = () => { + const data: ChartProps = { + chartTitle: 'Line Chart', + lineChartData: [ + { + legend: 'From_Legacy_to_O365', + data: [ + { + x: new Date('2020-03-03T00:00:00.000Z'), + y: 297, + }, + { + x: new Date('2020-03-04T00:00:00.000Z'), + y: 284, + }, + { + x: new Date('2020-03-05T00:00:00.000Z'), + y: 282, + }, + { + x: new Date('2020-03-06T00:00:00.000Z'), + y: 294, + }, + { + x: new Date('2020-03-07T00:00:00.000Z'), + y: 294, + }, + { + x: new Date('2020-03-08T00:00:00.000Z'), + y: 300, + }, + { + x: new Date('2020-03-09T00:00:00.000Z'), + y: 298, + }, + ], + color: '#2c72a8', + lineOptions: { + lineBorderWidth: '4', + }, + }, + { + legend: 'All', + data: [ + { + x: new Date('2020-03-03T00:00:00.000Z'), + y: 292, + }, + { + x: new Date('2020-03-04T00:00:00.000Z'), + y: 287, + }, + { + x: new Date('2020-03-05T00:00:00.000Z'), + y: 287, + }, + { + x: new Date('2020-03-06T00:00:00.000Z'), + y: 292, + }, + { + x: new Date('2020-03-07T00:00:00.000Z'), + y: 287, + }, + { + x: new Date('2020-03-08T00:00:00.000Z'), + y: 297, + }, + { + x: new Date('2020-03-09T00:00:00.000Z'), + y: 292, + }, + ], + color: '#13a10e', + lineOptions: { + lineBorderWidth: '4', + }, + }, + ], + }; + + const rootStyle = { width: `700px`, height: `300px` }; + + return ( +
+
event 1 message
, + }, + { + event: 'event 2', + date: new Date('2020-03-04T00:00:00.000Z'), + onRenderCard: () =>
event 2 message
, + }, + { + event: 'event 3', + date: new Date('2020-03-04T00:00:00.000Z'), + onRenderCard: () =>
event 3 message
, + }, + { + event: 'event 4', + date: new Date('2020-03-06T00:00:00.000Z'), + onRenderCard: () =>
event 4 message
, + }, + { + event: 'event 5', + date: new Date('2020-03-08T00:00:00.000Z'), + onRenderCard: () =>
event 5 message
, + }, + ], + labelHeight: 18, + labelWidth: 50, + mergedLabel: (count: number) => `${count} events`, + }} + height={300} + width={700} + enablePerfOptimization={true} + /> +
+ ); +}; + +export const EventsRTL = getStoryVariant(Events, RTL); + +export const EventsDarkMode = getStoryVariant(Events, DARK_MODE); + +export const Multiple = () => { + const _onLegendClickHandler = (selectedLegend: string | string[] | null): void => { + if (selectedLegend !== null) { + console.log(`Selected legend - ${selectedLegend}`); + } + }; + + const points: LineChartPoints[] = [ + { + data: [ + { + x: new Date('2018/01/01'), + y: 10, + xAxisCalloutData: '2018/01/01', + yAxisCalloutData: '10%', + }, + { + x: new Date('2018/02/01'), + y: 30, + xAxisCalloutData: '2018/01/15', + yAxisCalloutData: '18%', + }, + { + x: new Date('2018/03/01'), + y: 10, + xAxisCalloutData: '2018/01/28', + yAxisCalloutData: '24%', + }, + { + x: new Date('2018/04/01'), + y: 30, + xAxisCalloutData: '2018/02/01', + yAxisCalloutData: '25%', + }, + { + x: new Date('2018/05/01'), + y: 10, + xAxisCalloutData: '2018/03/01', + yAxisCalloutData: '15%', + }, + { + x: new Date('2018/06/01'), + y: 30, + xAxisCalloutData: '2018/03/15', + yAxisCalloutData: '30%', + }, + ], + legend: 'First', + lineOptions: { + lineBorderWidth: '4', + }, + onLegendClick: _onLegendClickHandler, + }, + { + data: [ + { x: new Date('2018/01/01'), y: 30 }, + { x: new Date('2018/02/01'), y: 50 }, + { x: new Date('2018/03/01'), y: 30 }, + { x: new Date('2018/04/01'), y: 50 }, + { x: new Date('2018/05/01'), y: 30 }, + { x: new Date('2018/06/01'), y: 50 }, + ], + legend: 'Second', + lineOptions: { + lineBorderWidth: '4', + }, + onLegendClick: _onLegendClickHandler, + }, + { + data: [ + { x: new Date('2018/01/01'), y: 50 }, + { x: new Date('2018/02/01'), y: 70 }, + { x: new Date('2018/03/01'), y: 50 }, + { x: new Date('2018/04/01'), y: 70 }, + { x: new Date('2018/05/01'), y: 50 }, + { x: new Date('2018/06/01'), y: 70 }, + ], + legend: 'Third', + lineOptions: { + lineBorderWidth: '4', + }, + onLegendClick: _onLegendClickHandler, + }, + { + data: [ + { x: new Date('2018/01/01'), y: 70 }, + { x: new Date('2018/02/01'), y: 90 }, + { x: new Date('2018/03/01'), y: 70 }, + { x: new Date('2018/04/01'), y: 90 }, + { x: new Date('2018/05/01'), y: 70 }, + { x: new Date('2018/06/01'), y: 90 }, + ], + legend: 'Fourth', + lineOptions: { + lineBorderWidth: '4', + }, + onLegendClick: _onLegendClickHandler, + }, + { + data: [ + { x: new Date('2018/01/01'), y: 90 }, + { x: new Date('2018/02/01'), y: 110 }, + { x: new Date('2018/03/01'), y: 90 }, + { x: new Date('2018/04/01'), y: 110 }, + { x: new Date('2018/05/01'), y: 90 }, + { x: new Date('2018/06/01'), y: 110 }, + ], + legend: 'Fifth', + lineOptions: { + lineBorderWidth: '4', + }, + onLegendClick: _onLegendClickHandler, + }, + { + data: [ + { x: new Date('2018/01/01'), y: 110 }, + { x: new Date('2018/02/01'), y: 130 }, + { x: new Date('2018/03/01'), y: 110 }, + { x: new Date('2018/04/01'), y: 130 }, + { x: new Date('2018/05/01'), y: 110 }, + { x: new Date('2018/06/01'), y: 130 }, + ], + legend: 'Sixth', + lineOptions: { + lineBorderWidth: '4', + }, + onLegendClick: _onLegendClickHandler, + }, + { + data: [ + { x: new Date('2018/01/01'), y: 130 }, + { x: new Date('2018/02/01'), y: 150 }, + { x: new Date('2018/03/01'), y: 130 }, + { x: new Date('2018/04/01'), y: 150 }, + { x: new Date('2018/05/01'), y: 130 }, + { x: new Date('2018/06/01'), y: 150 }, + ], + legend: 'Seventh', + lineOptions: { + lineBorderWidth: '4', + }, + onLegendClick: _onLegendClickHandler, + }, + { + data: [ + { x: new Date('2018/01/01'), y: 150 }, + { x: new Date('2018/02/01'), y: 170 }, + { x: new Date('2018/03/01'), y: 150 }, + { x: new Date('2018/04/01'), y: 170 }, + { x: new Date('2018/05/01'), y: 150 }, + { x: new Date('2018/06/01'), y: 170 }, + ], + legend: 'Eight', + lineOptions: { + lineBorderWidth: '4', + }, + onLegendClick: _onLegendClickHandler, + }, + { + data: [ + { x: new Date('2018/01/01'), y: 170 }, + { x: new Date('2018/02/01'), y: 190 }, + { x: new Date('2018/03/01'), y: 170 }, + { x: new Date('2018/04/01'), y: 190 }, + { x: new Date('2018/05/01'), y: 170 }, + { x: new Date('2018/06/01'), y: 190 }, + ], + legend: 'Ninth', + lineOptions: { + lineBorderWidth: '4', + }, + onLegendClick: _onLegendClickHandler, + }, + { + data: [ + { x: new Date('2018/01/01'), y: 190 }, + { x: new Date('2018/02/01'), y: 210 }, + { x: new Date('2018/03/01'), y: 190 }, + { x: new Date('2018/04/01'), y: 210 }, + { x: new Date('2018/05/01'), y: 190 }, + { x: new Date('2018/06/01'), y: 210 }, + ], + legend: 'Tenth', + lineOptions: { + lineBorderWidth: '4', + }, + onLegendClick: _onLegendClickHandler, + }, + { + data: [ + { x: new Date('2018/01/01'), y: 210 }, + { x: new Date('2018/02/01'), y: 230 }, + { x: new Date('2018/03/01'), y: 210 }, + { x: new Date('2018/04/01'), y: 230 }, + { x: new Date('2018/05/01'), y: 210 }, + { x: new Date('2018/06/01'), y: 230 }, + ], + legend: 'Eleventh', + lineOptions: { + lineBorderWidth: '4', + }, + onLegendClick: _onLegendClickHandler, + }, + { + data: [ + { x: new Date('2018/01/01'), y: 230 }, + { x: new Date('2018/02/01'), y: 250 }, + { x: new Date('2018/03/01'), y: 230 }, + { x: new Date('2018/04/01'), y: 250 }, + { x: new Date('2018/05/01'), y: 230 }, + { x: new Date('2018/06/01'), y: 250 }, + ], + legend: 'Tweleth', + lineOptions: { + lineBorderWidth: '4', + }, + onLegendClick: _onLegendClickHandler, + }, + ]; + + const data: ChartProps = { + chartTitle: 'Line Chart', + lineChartData: points, + }; + const rootStyle = { width: `${700}px`, height: `${300}px` }; + const timeFormat = '%m/%d'; + // Passing tick values is optional, for more control. + // If you do not pass them the line chart will render them for you based on D3's standard. + const tickValues: Date[] = [ + new Date('01-01-2018'), + new Date('02-01-2018'), + new Date('03-01-2018'), + new Date('04-01-2018'), + new Date('05-01-2018'), + new Date('06-01-2018'), + new Date('07-01-2018'), + ]; + const colorFillBarData = [ + { + legend: 'Time range 1', + color: DataVizPalette.color19, + data: [ + { + startX: new Date('2018/01/06'), + endX: new Date('2018/01/25'), + }, + ], + }, + { + legend: 'Time range 2', + color: DataVizPalette.color20, + data: [ + { + startX: new Date('2018/01/18'), + endX: new Date('2018/02/20'), + }, + { + startX: new Date('2018/04/17'), + endX: new Date('2018/05/10'), + }, + ], + applyPattern: true, + }, + ]; + return ( +
+ +
+ ); +}; + +export const MultipleRTL = getStoryVariant(Multiple, RTL); + +export const MultipleDarkMode = getStoryVariant(Multiple, DARK_MODE); + +export const Gaps = () => { + const _calculateCalloutDescription = (calloutDataProps: CustomizedCalloutData): string | undefined => { + if (calloutDataProps.values.filter(value => value.legend === 'Low Confidence Data*').length > 0) { + return '* This data was below our confidence threshold.'; + } + return undefined; + }; + + const data: ChartProps = { + chartTitle: 'Line Chart', + lineChartData: [ + { + legend: 'Confidence Level', + legendShape: 'dottedLine', + hideNonActiveDots: true, + lineOptions: { + strokeDasharray: '5', + strokeLinecap: 'butt', + strokeWidth: '2', + lineBorderWidth: '4', + }, + data: [ + { + x: new Date('2020-03-03T00:00:00.000Z'), + y: 250000, + hideCallout: true, + }, + { + x: new Date('2020-03-10T00:00:00.000Z'), + y: 250000, + hideCallout: true, + }, + ], + color: '#000000', + }, + { + legend: 'Normal Data', + gaps: [ + { + startIndex: 3, + endIndex: 4, + }, + { + startIndex: 6, + endIndex: 7, + }, + { + startIndex: 1, + endIndex: 2, + }, + ], + hideNonActiveDots: true, + lineOptions: { + lineBorderWidth: '4', + }, + data: [ + { + x: new Date('2020-03-03T00:00:00.000Z'), + y: 216000, + }, + { + x: new Date('2020-03-03T10:30:00.000Z'), + y: 218123, + hideCallout: true, + }, + // gap here + { + x: new Date('2020-03-03T11:00:00.000Z'), + y: 219000, + hideCallout: true, + }, + { + x: new Date('2020-03-04T00:00:00.000Z'), + y: 248000, + hideCallout: true, + }, + // gap here + { + x: new Date('2020-03-05T00:00:00.000Z'), + y: 252000, + hideCallout: true, + }, + { + x: new Date('2020-03-06T00:00:00.000Z'), + y: 274000, + }, + { + x: new Date('2020-03-07T00:00:00.000Z'), + y: 260000, + hideCallout: true, + }, + // gap here + { + x: new Date('2020-03-08T00:00:00.000Z'), + y: 300000, + hideCallout: true, + }, + { + x: new Date('2020-03-08T12:00:00.000Z'), + y: 218000, + }, + { + x: new Date('2020-03-09T00:00:00.000Z'), + y: 218000, + }, + { + x: new Date('2020-03-10T00:00:00.000Z'), + y: 269000, + }, + ], + color: '#2c72a8', + }, + { + legend: 'Low Confidence Data*', + legendShape: 'dottedLine', + hideNonActiveDots: true, + lineOptions: { + strokeDasharray: '2', + strokeDashoffset: '-1', + strokeLinecap: 'butt', + lineBorderWidth: '4', + }, + gaps: [ + { + startIndex: 3, + endIndex: 4, + }, + { + startIndex: 1, + endIndex: 2, + }, + ], + data: [ + { + x: new Date('2020-03-03T10:30:00.000Z'), + y: 218123, + }, + { + x: new Date('2020-03-03T11:00:00.000Z'), + y: 219000, + }, + // gap here + { + x: new Date('2020-03-04T00:00:00.000Z'), + y: 248000, + }, + { + x: new Date('2020-03-05T00:00:00.000Z'), + y: 252000, + }, + // gap here + { + x: new Date('2020-03-07T00:00:00.000Z'), + y: 260000, + }, + { + x: new Date('2020-03-08T00:00:00.000Z'), + y: 300000, + }, + ], + color: '#2c72a8', + }, + { + legend: 'Green Data', + lineOptions: { + lineBorderWidth: '4', + }, + data: [ + { + x: new Date('2020-03-03T00:00:00.000Z'), + y: 297000, + }, + { + x: new Date('2020-03-04T00:00:00.000Z'), + y: 284000, + }, + { + x: new Date('2020-03-05T00:00:00.000Z'), + y: 282000, + }, + { + x: new Date('2020-03-06T00:00:00.000Z'), + y: 294000, + }, + { + x: new Date('2020-03-07T00:00:00.000Z'), + y: 224000, + }, + { + x: new Date('2020-03-08T00:00:00.000Z'), + y: 300000, + }, + { + x: new Date('2020-03-09T00:00:00.000Z'), + y: 298000, + }, + { + x: new Date('2020-03-10T00:00:00.000Z'), + y: 299000, + }, + ], + color: '#13a10e', + }, + ], + }; + + const rootStyle = { width: `${700}px`, height: `${300}px` }; + const margins = { left: 35, top: 20, bottom: 35, right: 20 }; + + return ( + <> +
+ +
+ + ); +}; + +export const GapsRTL = getStoryVariant(Gaps, RTL); + +export const GapsDarkMode = getStoryVariant(Gaps, DARK_MODE); diff --git a/apps/vr-tests-react-components/src/stories/Charts/SparklineChart.stories.tsx b/apps/vr-tests-react-components/src/stories/Charts/SparklineChart.stories.tsx new file mode 100644 index 0000000000000..db69e972fa61f --- /dev/null +++ b/apps/vr-tests-react-components/src/stories/Charts/SparklineChart.stories.tsx @@ -0,0 +1,393 @@ +import * as React from 'react'; +import type { Meta } from '@storybook/react'; +import { DARK_MODE, getStoryVariant, RTL, TestWrapperDecorator } from '../../utilities'; +import { Steps, StoryWright } from 'storywright'; +import { ChartProps, Sparkline } from '@fluentui/react-charts-preview'; + +export default { + title: 'Charts/SparkLineChart', + + decorators: [ + (story, context) => TestWrapperDecorator(story, context), + (story, context) => { + const steps = new Steps().snapshot('default', { cropTo: '.testWrapper' }).end(); + return {story(context)}; + }, + ], +} satisfies Meta; + +export const Basic = () => { + const sl1: ChartProps = { + chartTitle: '10.21', + lineChartData: [ + { + legend: '19.64', + color: '#00AA00', + data: [ + { + x: 1, + y: 58.13, + }, + { + x: 2, + y: 140.98, + }, + { + x: 3, + y: 20, + }, + { + x: 4, + y: 89.7, + }, + { + x: 5, + y: 99, + }, + { + x: 6, + y: 13.28, + }, + { + x: 7, + y: 31.32, + }, + { + x: 8, + y: 10.21, + }, + ], + }, + ], + }; + const sl2: ChartProps = { + chartTitle: '49.44', + lineChartData: [ + { + legend: '19.64', + color: '#E60000', + data: [ + { + x: 1, + y: 29.13, + }, + { + x: 2, + y: 70.98, + }, + { + x: 3, + y: 60, + }, + { + x: 4, + y: 89.7, + }, + { + x: 5, + y: 19, + }, + { + x: 6, + y: 49.44, + }, + ], + }, + ], + }; + const sl3: ChartProps = { + chartTitle: '49.44', + lineChartData: [ + { + legend: '19.64', + color: '#00AA00', + data: [ + { + x: 1, + y: 29.13, + }, + { + x: 2, + y: 70.98, + }, + { + x: 3, + y: 60, + }, + { + x: 4, + y: 89.7, + }, + { + x: 5, + y: 19, + }, + { + x: 6, + y: 49.44, + }, + ], + }, + ], + }; + const sl4: ChartProps = { + chartTitle: '49.44', + lineChartData: [ + { + legend: '464.64', + color: '#E60000', + data: [ + { + x: 1, + y: 29.13, + }, + { + x: 2, + y: 70.98, + }, + { + x: 3, + y: 60, + }, + { + x: 4, + y: 89.7, + }, + { + x: 5, + y: 19, + }, + { + x: 6, + y: 49.44, + }, + ], + }, + ], + }; + const sl5: ChartProps = { + chartTitle: '49.44', + lineChartData: [ + { + legend: '46.49', + color: '#E3008C', + data: [ + { + x: 1, + y: 29.13, + }, + { + x: 2, + y: 70.98, + }, + { + x: 3, + y: 60, + }, + { + x: 4, + y: 89.7, + }, + { + x: 5, + y: 19, + }, + { + x: 6, + y: 49.44, + }, + ], + }, + ], + }; + const sl6: ChartProps = { + chartTitle: '49.44', + lineChartData: [ + { + legend: '49.44', + color: '#627CEF', + data: [ + { + x: new Date('2020-03-03T00:00:00.000Z'), + y: 29.13, + }, + { + x: new Date('2020-03-04T00:00:00.000Z'), + y: 70.98, + }, + { + x: new Date('2020-03-05T00:00:00.000Z'), + y: 60, + }, + { + x: new Date('2020-03-07T00:00:00.000Z'), + y: 89.7, + }, + { + x: new Date('2020-03-12T00:00:00.000Z'), + y: 19, + }, + { + x: new Date('2020-03-15T00:00:00.000Z'), + y: 49.44, + }, + ], + }, + ], + }; + const sl7: ChartProps = { + chartTitle: '49.44', + lineChartData: [ + { + legend: '49.44', + color: '#0078D4', + data: [ + { + x: 1, + y: 29.13, + }, + { + x: 2, + y: 70.98, + }, + { + x: 3, + y: 60, + }, + { + x: 4, + y: 89.7, + }, + { + x: 5, + y: 19, + }, + { + x: 6, + y: 49.44, + }, + ], + }, + ], + }; + const sl8: ChartProps = { + chartTitle: '541.44', + lineChartData: [ + { + legend: '541.44', + color: '#0078D4', + data: [ + { + x: 1, + y: 291.13, + }, + { + x: 2, + y: 170.98, + }, + { + x: 3, + y: 260, + }, + { + x: 4, + y: 89.7, + }, + { + x: 5, + y: 664, + }, + { + x: 6, + y: 66.44, + }, + { + x: 7, + y: 541.44, + }, + { + x: 8, + y: 32.44, + }, + { + x: 9, + y: 499.14, + }, + { + x: 10, + y: 350.48, + }, + { + x: 11, + y: 32.44, + }, + { + x: 12, + y: 400.44, + }, + ], + }, + ], + }; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Row 1 + +
Row 2 + +
Row 3 + +
Row 4 + +
Row 5 + +
Row 6 + +
Row 7 + +
Row 8 + +
+
+ ); +}; + +export const BasicDarkMode = getStoryVariant(Basic, DARK_MODE); + +export const BasicRTL = getStoryVariant(Basic, RTL); diff --git a/apps/vr-tests-react-components/src/stories/Charts/VerticalBarChart.stories.tsx b/apps/vr-tests-react-components/src/stories/Charts/VerticalBarChart.stories.tsx new file mode 100644 index 0000000000000..e8e52bbb7e388 --- /dev/null +++ b/apps/vr-tests-react-components/src/stories/Charts/VerticalBarChart.stories.tsx @@ -0,0 +1,284 @@ +import * as React from 'react'; +import type { Meta } from '@storybook/react'; +import { DARK_MODE, getStoryVariant, RTL, TestWrapperDecorator } from '../../utilities'; +import { Steps, StoryWright } from 'storywright'; +import { LineChartLineOptions, VerticalBarChartDataPoint, VerticalBarChart } from '@fluentui/react-charts-preview'; + +export default { + title: 'Charts/VerticalBarChart', + + decorators: [ + (story, context) => TestWrapperDecorator(story, context), + (story, context) => { + const steps = new Steps().snapshot('default', { cropTo: '.testWrapper' }).end(); + return {story(context)}; + }, + ], +} satisfies Meta; + +export const BasicSecondaryYAxis = () => { + const points: VerticalBarChartDataPoint[] = [ + { + x: 0, + y: 10000, + legend: 'Oranges', + color: '#0078d4', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '10%', + lineData: { + y: 7000, + yAxisCalloutData: '34%', + }, + }, + { + x: 10000, + y: 50000, + legend: 'Dogs', + color: '#005a9e', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '20%', + lineData: { + y: 30000, + }, + }, + { + x: 25000, + y: 30000, + legend: 'Apples', + color: '#00188f', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '37%', + lineData: { + y: 3000, + yAxisCalloutData: '43%', + }, + }, + { + x: 40000, + y: 13000, + legend: 'Bananas', + color: '#00bcf2', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '88%', + }, + { + x: 52000, + y: 43000, + legend: 'Giraffes', + color: '#0078d4', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '71%', + lineData: { + y: 30000, + }, + }, + { + x: 68000, + y: 30000, + legend: 'Cats', + color: '#005a9e', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '40%', + lineData: { + y: 5000, + }, + }, + { + x: 80000, + y: 20000, + legend: 'Elephants', + color: '#0078d4', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '87%', + lineData: { + y: 16000, + }, + }, + { + x: 92000, + y: 45000, + legend: 'Monkeys', + color: '#00bcf2', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '33%', + lineData: { + y: 40000, + yAxisCalloutData: '45%', + }, + }, + ]; + + const lineOptions: LineChartLineOptions = { lineBorderWidth: '2' }; + + const rootStyle = { width: `${650}px`, height: `${350}px` }; + + return ( +
+ +
+ ); +}; + +BasicSecondaryYAxis.storyName = 'Basic - Secondary Y Axis'; + +export const BasicSecondaryYAxisDarkMode = getStoryVariant(BasicSecondaryYAxis, DARK_MODE); + +export const BasicSecondaryYAxisRTL = getStoryVariant(BasicSecondaryYAxis, RTL); + +export const DateAxisVbc = () => { + const points: VerticalBarChartDataPoint[] = [ + { + x: new Date('2018/10/01'), + y: 3500, + color: '#627CEF', + }, + { + x: new Date('2019/02/01'), + y: 2500, + color: '#C19C00', + }, + { + x: new Date('2019/05/01'), + y: 1900, + color: '#E650AF', + }, + { + x: new Date('2019/07/01'), + y: 2800, + color: '#0E7878', + }, + ]; + const timeFormat = '%m/%d'; + const tickValues: Date[] = [ + new Date('10-01-2018'), + new Date('02-01-2019'), + new Date('05-01-2019'), + new Date('07-01-2019'), + ]; + + const rootStyle = { width: '650px', height: '500px' }; + return ( + <> +
+ +
+ + ); +}; + +DateAxisVbc.storyName = 'Date Axis- VBC'; + +export const DateAxisVbcDarkMode = getStoryVariant(DateAxisVbc, DARK_MODE); + +export const DateAxisVbcRTL = getStoryVariant(DateAxisVbc, RTL); + +export const DynamicWrapLabels = () => { + const points: VerticalBarChartDataPoint[] = [ + { + x: 'Simple Text', + y: 1000, + color: '#0078d4', + }, + { + x: 'Showing all text here', + y: 5000, + color: '#005a9e', + }, + { + x: 'Large data, showing all text', + y: 3000, + color: '#00188f', + }, + { + x: 'Data', + y: 2000, + color: '#0078d4', + }, + ]; + + const rootStyle = { width: '650px', height: '350px' }; + return ( +
+ +
+ ); +}; +DynamicWrapLabels.storyName = 'Dynamic - Wrap Labels'; + +export const DynamicWrapLabelsRTL = getStoryVariant(DynamicWrapLabels, RTL); + +export const DynamicWrapLabelsDarkMode = getStoryVariant(DynamicWrapLabels, DARK_MODE); + +export const RotatedLabelHideLegends = () => { + const points: VerticalBarChartDataPoint[] = [ + { + x: 'This is a medium long label. ', + y: 3500, + color: '#627CEF', + }, + { + x: 'This is a long label This is a long label', + y: 2500, + color: '#C19C00', + }, + { + x: 'This label is as long as the previous one', + y: 1900, + color: '#E650AF', + }, + { + x: 'A short label', + y: 2800, + color: '#0E7878', + }, + ]; + + const rootStyle = { width: '650px', height: '500px' }; + return ( + <> +
+ +
+ + ); +}; + +RotatedLabelHideLegends.storyName = 'Rotated Label- Hide Legends'; + +export const RotatedLabelHideLegendsDarkMode = getStoryVariant(RotatedLabelHideLegends, DARK_MODE); + +export const RotatedLabelHideLegendsRTL = getStoryVariant(RotatedLabelHideLegends, RTL); diff --git a/change/@fluentui-react-charts-preview-ce35d45e-2f3e-40d5-9d4d-fafddc144d92.json b/change/@fluentui-react-charts-preview-ce35d45e-2f3e-40d5-9d4d-fafddc144d92.json new file mode 100644 index 0000000000000..0b3c0d268f386 --- /dev/null +++ b/change/@fluentui-react-charts-preview-ce35d45e-2f3e-40d5-9d4d-fafddc144d92.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: Create charts components as native v9 controls.", + "packageName": "@fluentui/react-charts-preview", + "email": "98592573+AtishayMsft@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/package.json b/package.json index 0341dbc009c36..c375818bb4d1e 100644 --- a/package.json +++ b/package.json @@ -364,6 +364,8 @@ "packages/fluentui/*", "packages/react-components/*", "packages/react-components/*/*", + "packages/charts/*", + "packages/charts/*/*", "scripts/*", "tools/*", "typings" diff --git a/packages/charts/react-charts-preview/library/.babelrc.json b/packages/charts/react-charts-preview/library/.babelrc.json new file mode 100644 index 0000000000000..630deaf765c49 --- /dev/null +++ b/packages/charts/react-charts-preview/library/.babelrc.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../../.babelrc-v9.json", + "plugins": ["annotate-pure-calls", "@babel/transform-react-pure-annotations"] +} diff --git a/packages/charts/react-charts-preview/library/.eslintrc.json b/packages/charts/react-charts-preview/library/.eslintrc.json new file mode 100644 index 0000000000000..ceea884c70dcc --- /dev/null +++ b/packages/charts/react-charts-preview/library/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["plugin:@fluentui/eslint-plugin/react"], + "root": true +} diff --git a/packages/charts/react-charts-preview/library/.swcrc b/packages/charts/react-charts-preview/library/.swcrc new file mode 100644 index 0000000000000..b4ffa86dee306 --- /dev/null +++ b/packages/charts/react-charts-preview/library/.swcrc @@ -0,0 +1,30 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "exclude": [ + "/testing", + "/**/*.cy.ts", + "/**/*.cy.tsx", + "/**/*.spec.ts", + "/**/*.spec.tsx", + "/**/*.test.ts", + "/**/*.test.tsx" + ], + "jsc": { + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": false, + "dynamicImport": false + }, + "externalHelpers": true, + "transform": { + "react": { + "runtime": "classic", + "useSpread": true + } + }, + "target": "es2019" + }, + "minify": false, + "sourceMaps": true +} diff --git a/packages/charts/react-charts-preview/library/LICENSE b/packages/charts/react-charts-preview/library/LICENSE new file mode 100644 index 0000000000000..e21684b95d1c7 --- /dev/null +++ b/packages/charts/react-charts-preview/library/LICENSE @@ -0,0 +1,15 @@ +@fluentui/react-charts-preview + +Copyright (c) Microsoft Corporation + +All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED _AS IS_, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Note: Usage of the fonts and icons referenced in Fluent UI React is subject to the terms listed at https://aka.ms/fluentui-assets-license diff --git a/packages/charts/react-charts-preview/library/README.md b/packages/charts/react-charts-preview/library/README.md new file mode 100644 index 0000000000000..6d6798421c55e --- /dev/null +++ b/packages/charts/react-charts-preview/library/README.md @@ -0,0 +1,5 @@ +# @fluentui/react-charts-preview + +**React Charts components for [Fluent UI React](https://react.fluentui.dev/)** + +These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. diff --git a/packages/charts/react-charts-preview/library/config/api-extractor.json b/packages/charts/react-charts-preview/library/config/api-extractor.json new file mode 100644 index 0000000000000..27d19a3cc3374 --- /dev/null +++ b/packages/charts/react-charts-preview/library/config/api-extractor.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "@fluentui/scripts-api-extractor/api-extractor.common.v-next.json", + "mainEntryPointFilePath": "/../../../../../../dist/out-tsc/types/packages/charts//library/src/index.d.ts" +} diff --git a/packages/charts/react-charts-preview/library/config/tests.js b/packages/charts/react-charts-preview/library/config/tests.js new file mode 100644 index 0000000000000..765bbd0949cff --- /dev/null +++ b/packages/charts/react-charts-preview/library/config/tests.js @@ -0,0 +1,7 @@ +/** Jest test setup file. */ +const { configure } = require('enzyme'); +require('@testing-library/jest-dom'); +const Adapter = require('@wojtekmaj/enzyme-adapter-react-17'); + +// Configure enzyme. +configure({ adapter: new Adapter() }); diff --git a/packages/charts/react-charts-preview/library/docs/Spec.md b/packages/charts/react-charts-preview/library/docs/Spec.md new file mode 100644 index 0000000000000..203a480c64abc --- /dev/null +++ b/packages/charts/react-charts-preview/library/docs/Spec.md @@ -0,0 +1,48 @@ +# @fluentui/react-charts-preview Spec + +## Background + +Fluent UI React charts is a set of modern, accessible, interactive, lightweight and highly customizable visualization library representing the Microsoft design system. The charts are used across 100+ projects inside Microsoft including Microsoft 365 and Azure. + +The library is built using D3 (Data Driven Documents) and fluent v9 design system. + +## Sample Code + +Refer to the docsite for usage examples for each chart. + +## Migration + +The v9 charts maintain feature parity with their v8 equivalent. + +The following controls have been ported over to v9. +For the remaining controls, refer to the migration guide to consume them in v9. https://react.fluentui.dev/?path=/docs/concepts-migration-from-v8-components-charts-migration--docs + +| v8 control | v9 control | +| ----------------------------------- | -------------------- | +| Area Chart | Planned | +| Donut Chart | Donut Chart | +| Gauge Chart | | +| Heatmap Chart | | +| Horizontal Bar Chart | Horizontal Bar Chart | +| Horizontal Bar Chart with Axis | Planned | +| Horizontal Bar Chart - Stacked | | +| Horizontal Bar Chart - MultiStacked | | +| Legends | Legends | +| Line Chart | Line Chart | +| Pie Chart | | +| Sankey Chart | | +| Sparkline Chart | Sparkline Chart | +| Tree Chart | | +| Vertical Bar Chart | Vertical Bar Chart | +| Vertical Bar Chart - Grouped | | +| Vertical Bar Chart - Stacked | Planned | + +## Behaviors + +Refer to our technical documentation for detailed information about behavior of chart controls. +https://microsoft.github.io/fluentui-charting-contrib/docs/Technical%20Details + +## Accessibility + +Refer this document for details about Accessibility aspects for chart controls. +https://microsoft.github.io/fluentui-charting-contrib/docs/Accessibility diff --git a/packages/charts/react-charts-preview/library/etc/react-charts-preview.api.md b/packages/charts/react-charts-preview/library/etc/react-charts-preview.api.md new file mode 100644 index 0000000000000..c78e97edaa731 --- /dev/null +++ b/packages/charts/react-charts-preview/library/etc/react-charts-preview.api.md @@ -0,0 +1,914 @@ +## API Report File for "@fluentui/react-charts-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +/// + +import * as React_2 from 'react'; +import { SVGProps } from 'react'; +import { TimeLocaleDefinition } from 'd3-time-format'; + +// @public (undocumented) +export interface AccessibilityProps { + ariaDescribedBy?: string; + ariaLabel?: string; + ariaLabelledBy?: string; +} + +// @public (undocumented) +export interface Basestate { + // (undocumented) + activeLegend?: string; + // (undocumented) + color?: string; + // (undocumented) + containerHeight?: number; + // (undocumented) + containerWidth?: number; + // (undocumented) + dataForHoverCard?: number; + // (undocumented) + _height?: number; + // (undocumented) + hoveredLineColor?: string; + // (undocumented) + hoverXValue?: string | number | null; + // (undocumented) + hoverYValue?: string | number | null; + // (undocumented) + isCalloutVisible: boolean; + // (undocumented) + isLegendHovered?: boolean; + // (undocumented) + isLegendSelected?: boolean; + // (undocumented) + lineColor?: string; + // (undocumented) + refSelected?: any; + // (undocumented) + selectedLegend?: string; + // (undocumented) + _width?: number; + // (undocumented) + xCalloutValue?: string; + // (undocumented) + yCalloutValue?: string; + // (undocumented) + YValueHover?: { + legend?: string; + y?: number; + color?: string; + }[]; +} + +// @public (undocumented) +export const CartesianChart: React_2.FunctionComponent; + +// @public +export interface CartesianChartProps { + calloutProps?: Partial; + // @deprecated + chartLabel?: string; + className?: string; + customDateTimeFormatter?: (dateTime: Date) => string; + customProps?: (dataPointCalloutProps: any) => ChartPopoverProps; + dateLocalizeOptions?: Intl.DateTimeFormatOptions; + enabledLegendsWrapLines?: boolean; + enableReflow?: boolean; + height?: number; + hideLegend?: boolean; + hideTickOverlap?: boolean; + hideTooltip?: boolean; + href?: string; + // (undocumented) + legendProps?: Partial; + // (undocumented) + legendsOverflowText?: any; + margins?: Margins; + noOfCharsToTruncate?: number; + onResize?: (width: number, height: number) => void; + parentRef?: HTMLElement | null; + responsive?: boolean; + rotateXAxisLables?: boolean; + secondaryYAxistitle?: string; + secondaryYScaleOptions?: { + yMinValue?: number; + yMaxValue?: number; + }; + showXAxisLablesTooltip?: boolean; + strokeWidth?: number; + styles?: CartesianChartStyles; + svgProps?: React_2.SVGProps; + tickFormat?: string; + tickPadding?: number; + tickValues?: number[] | Date[] | string[] | undefined; + timeFormatLocale?: TimeLocaleDefinition; + useUTC?: string | boolean; + width?: number; + wrapXAxisLables?: boolean; + xAxisTickCount?: number; + xAxisTickPadding?: number; + xAxistickSize?: number; + xAxisTitle?: string; + xMaxValue?: number; + yAxisTickCount?: number; + yAxisTickFormat?: any; + yAxisTitle?: string; + yMaxValue?: number; + yMinValue?: number; +} + +// @public +export interface CartesianChartStyleProps { + className?: string; + color?: string; + height?: number; + href?: string; + lineColor?: string; + shouldHighlight?: boolean; + toDrawShape?: boolean; + useRtl?: boolean; + width?: number; +} + +// @public +export interface CartesianChartStyles { + axisTitle?: string; + calloutBlockContainer?: string; + calloutBlockContainertoDrawShapefalse?: string; + calloutBlockContainertoDrawShapetrue?: string; + calloutContentRoot?: string; + calloutContentX?: string; + calloutContentY?: string; + calloutDateTimeContainer?: string; + calloutInfoContainer?: string; + calloutlegendText?: string; + chartTitle?: string; + chartWrapper?: string; + descriptionMessage?: string; + hover?: string; + legendContainer?: string; + opacityChangeOnHover?: string; + root?: string; + shapeStyles?: string; + tooltip?: string; + xAxis?: string; + yAxis?: string; +} + +// @public +export type ChartDataMode = 'default' | 'fraction' | 'percentage'; + +// @public (undocumented) +export interface ChartDataPoint { + callOutAccessibilityData?: AccessibilityProps; + color?: string; + data?: number; + horizontalBarChartdata?: HorizontalDataPoint; + legend?: string; + onClick?: VoidFunction; + placeHolder?: boolean; + xAxisCalloutData?: string; + yAxisCalloutData?: string; +} + +// @public (undocumented) +export const ChartPopover: React_2.FunctionComponent; + +// @public (undocumented) +export interface ChartPopoverProps { + // (undocumented) + clickPosition?: { + x: number; + y: number; + }; + // (undocumented) + color?: string; + // (undocumented) + culture?: string; + // (undocumented) + customCallout?: { + customizedCallout?: JSX.Element; + customCalloutProps?: ChartPopoverProps; + }; + // (undocumented) + descriptionMessage?: string; + // (undocumented) + hoverXValue?: string | number; + // (undocumented) + isCalloutForStack?: boolean; + // (undocumented) + isCartesian?: boolean; + // (undocumented) + isPopoverOpen?: boolean; + // (undocumented) + legend?: string | number | Date; + // (undocumented) + ratio?: [number, number]; + // (undocumented) + xAxisCalloutAccessibilityData?: { + ariaLabel?: string; + data?: string; + }; + // (undocumented) + xCalloutValue?: string; + // (undocumented) + XValue?: string; + // (undocumented) + yCalloutValue?: string; + // (undocumented) + YValue?: string | number | Date; + // (undocumented) + YValueHover?: YValueHover[]; +} + +// @public (undocumented) +export interface ChartProps { + chartData?: ChartDataPoint[]; + chartDataAccessibilityData?: AccessibilityProps; + chartTitle?: string; + chartTitleAccessibilityData?: AccessibilityProps; + lineChartData?: LineChartPoints[]; + pointLineOptions?: SVGProps; + pointOptions?: SVGProps; +} + +// @public (undocumented) +export interface ChildProps { + // (undocumented) + containerHeight?: number; + // (undocumented) + containerWidth?: number; + // (undocumented) + optimizeLargeData?: boolean; + // (undocumented) + xScale?: any; + // (undocumented) + yScale?: any; + // (undocumented) + yScaleSecondary?: any; +} + +// @public (undocumented) +export interface ColorFillBarData { + // (undocumented) + endX: number | Date; + // (undocumented) + startX: number | Date; +} + +// @public (undocumented) +export interface ColorFillBarsProps { + // (undocumented) + applyPattern?: boolean; + // (undocumented) + color: string; + // (undocumented) + data: ColorFillBarData[]; + // (undocumented) + legend: string; + // (undocumented) + onLegendClick?: (selectedLegend: string | string[] | null) => void | undefined; +} + +// @public +export interface CustomizedCalloutData { + // (undocumented) + values: CustomizedCalloutDataPoint[]; + // (undocumented) + x: number | string | Date; +} + +// @public (undocumented) +export interface CustomizedCalloutDataPoint { + // (undocumented) + color: string; + // (undocumented) + legend: string; + // (undocumented) + xAxisCalloutData?: string; + // (undocumented) + y: number; + // (undocumented) + yAxisCalloutData?: string | { + [id: string]: number; + }; +} + +// @public (undocumented) +export interface DataPoint { + onClick?: VoidFunction; + x: number | string; + y: number; +} + +// @public (undocumented) +export const DataVizPalette: { + color1: string; + color2: string; + color3: string; + color4: string; + color5: string; + color6: string; + color7: string; + color8: string; + color9: string; + color10: string; + color11: string; + color12: string; + color13: string; + color14: string; + color15: string; + color16: string; + color17: string; + color18: string; + color19: string; + color20: string; + color21: string; + color22: string; + color23: string; + color24: string; + color25: string; + color26: string; + color27: string; + color28: string; + color29: string; + color30: string; + color31: string; + color32: string; + color33: string; + color34: string; + color35: string; + color36: string; + color37: string; + color38: string; + color39: string; + color40: string; + info: string; + disabled: string; + highError: string; + error: string; + warning: string; + success: string; + highSuccess: string; +}; + +// @public (undocumented) +export const DonutChart: React_2.FunctionComponent; + +// @public +export interface DonutChartProps extends CartesianChartProps { + calloutProps?: ChartPopoverProps; + culture?: string; + customProps?: (dataPointCalloutProps: ChartDataPoint) => ChartPopoverProps; + data?: ChartProps; + hideLabels?: boolean; + innerRadius?: number; + onRenderCalloutPerDataPoint?: (dataPointCalloutProps: ChartDataPoint) => JSX.Element | undefined; + showLabelsInPercent?: boolean; + styles?: DonutChartStyles; + valueInsideDonut?: string | number; +} + +// @public +export interface DonutChartStyleProps extends CartesianChartStyleProps { +} + +// @public +export interface DonutChartStyles { + chart?: string; + chartWrapper?: string; + legendContainer: string; + root?: string; +} + +// @public (undocumented) +export interface EventsAnnotationProps { + // (undocumented) + events: EventAnnotation[]; + // (undocumented) + labelColor?: string; + // (undocumented) + labelHeight?: number; + // (undocumented) + labelWidth?: number; + // (undocumented) + mergedLabel: (count: number) => string; + // (undocumented) + strokeColor?: string; +} + +// @public (undocumented) +export const getColorFromToken: (token: string, isDarkTheme?: boolean) => string; + +// @public (undocumented) +export const getNextColor: (index: number, offset?: number, isDarkTheme?: boolean) => string; + +// @public (undocumented) +export interface GroupedVerticalBarChartData { + name: string; + series: GVBarChartSeriesPoint[]; + stackCallOutAccessibilityData?: AccessibilityProps; +} + +// @public (undocumented) +export interface GVBarChartSeriesPoint { + callOutAccessibilityData?: AccessibilityProps; + color: string; + data: number; + key: string; + legend: string; + onClick?: VoidFunction; + xAxisCalloutData?: string; + yAxisCalloutData?: string; +} + +// @public (undocumented) +export interface GVDataPoint { + [key: string]: number | string; +} + +// @public (undocumented) +export interface GVForBarChart { + [key: string]: GVBarChartSeriesPoint; +} + +// @public (undocumented) +export interface GVSingleDataPoint { + [key: string]: GVDataPoint; +} + +// @public +export const HorizontalBarChart: React_2.FunctionComponent; + +// @public +export interface HorizontalBarChartProps extends React_2.RefAttributes { + barHeight?: number; + calloutProps?: ChartPopoverProps; + chartDataMode?: ChartDataMode; + className?: string; + color?: string; + culture?: string; + customProps?: (dataPointCalloutProps: ChartDataPoint) => ChartPopoverProps; + data?: ChartProps[]; + hideLabels?: boolean; + hideRatio?: boolean[]; + hideTooltip?: boolean; + onRenderCalloutPerHorizontalBar?: (props: ChartDataPoint) => JSX.Element | undefined; + showTriangle?: boolean; + styles?: HorizontalBarChartStyles; + variant?: HorizontalBarChartVariant; + width?: number; +} + +// @public +export interface HorizontalBarChartStyles { + barLabel: string; + barWrapper: string; + benchmarkContainer: string; + chart: string; + chartDataTextDenominator: string; + chartTitle: string; + chartTitleLeft: string; + chartTitleRight: string; + chartWrapper: string; + items: string; + root: string; + triangle: string; +} + +// @public (undocumented) +export enum HorizontalBarChartVariant { + // (undocumented) + AbsoluteScale = "absolute-scale", + // (undocumented) + PartToWhole = "part-to-whole" +} + +// @public (undocumented) +export interface HorizontalBarChartWithAxisDataPoint { + callOutAccessibilityData?: AccessibilityProps; + color?: string; + legend?: string; + onClick?: VoidFunction; + x: number; + xAxisCalloutData?: string; + y: number | string; + yAxisCalloutData?: string; +} + +// @public (undocumented) +export interface HorizontalDataPoint { + x: number; + y: number; +} + +// @public +export interface Legend { + action?: VoidFunction; + color: string; + hoverAction?: VoidFunction; + isLineLegendInBarChart?: boolean; + // (undocumented) + nativeButtonProps?: React_2.ButtonHTMLAttributes; + onMouseOutAction?: (isLegendFocused?: boolean) => void; + opacity?: number; + shape?: LegendShape; + stripePattern?: boolean; + title: string; +} + +// @public (undocumented) +export interface LegendDataItem { + legendColor: string; + legendText: string | number; +} + +// @public (undocumented) +export const Legends: React_2.FunctionComponent; + +// @public +export type LegendShape = 'default' | 'triangle' | keyof typeof Points | keyof typeof CustomPoints; + +// @public +export interface LegendsProps { + allowFocusOnLegends?: boolean; + canSelectMultipleLegends?: boolean; + centerLegends?: boolean; + className?: string; + defaultSelectedLegend?: string; + defaultSelectedLegends?: string[]; + enabledWrapLines?: boolean; + legends: Legend[]; + onChange?: (selectedLegends: string[], event: React_2.MouseEvent, currentLegend?: Legend) => void; + overflowStyles?: React_2.CSSProperties; + overflowText?: string; + shape?: LegendShape; + styles?: LegendsStyles; +} + +// @public +export interface LegendsStyles { + hoverChange?: string; + legend?: string; + rect?: string; + resizableArea?: string; + root?: string; + shape?: string; + text?: string; + triangle?: string; +} + +// @public (undocumented) +export interface LegendState { + // (undocumented) + activeLegend: string; + selectedLegends: LegendMap; +} + +// @public +export interface LegendStyleProps { + // (undocumented) + borderColor?: string; + // (undocumented) + className?: string; + // (undocumented) + colorOnSelectedState?: string; + // (undocumented) + isLineLegendInBarChart?: boolean; + // (undocumented) + opacity?: number; + // (undocumented) + overflow?: boolean; + // (undocumented) + stripePattern?: boolean; +} + +// @public +export const LineChart: React_2.FunctionComponent; + +// @public (undocumented) +export interface LineChartDataPoint { + callOutAccessibilityData?: AccessibilityProps; + hideCallout?: boolean; + onDataPointClick?: () => void; + x: number | Date; + xAxisCalloutAccessibilityData?: AccessibilityProps; + xAxisCalloutData?: string; + y: number; + yAxisCalloutData?: string | { + [id: string]: number; + }; +} + +// @public (undocumented) +export interface LineChartGap { + endIndex: number; + startIndex: number; +} + +// @public (undocumented) +export interface LineChartLineOptions extends SVGProps { + lineBorderColor?: string; + lineBorderWidth?: string | number; + strokeDasharray?: string | number; + strokeDashoffset?: string | number; + strokeLinecap?: 'butt' | 'round' | 'square' | 'inherit'; + strokeWidth?: number | string; +} + +// @public (undocumented) +export interface LineChartPoints { + color?: string; + data: LineChartDataPoint[]; + gaps?: LineChartGap[]; + hideNonActiveDots?: boolean; + legend: string; + legendShape?: LegendShape; + lineOptions?: LineChartLineOptions; + onLegendClick?: (selectedLegend: string | null | string[]) => void; + onLineClick?: () => void; + opacity?: number; +} + +// @public +export interface LineChartProps extends CartesianChartProps { + allowMultipleShapesForPoints?: boolean; + // (undocumented) + colorFillBars?: ColorFillBarsProps[]; + culture?: string; + data: ChartProps; + enablePerfOptimization?: boolean; + eventAnnotationProps?: EventsAnnotationProps; + getCalloutDescriptionMessage?: (calloutDataProps: CustomizedCalloutData) => string | undefined; + onRenderCalloutPerDataPoint?: RenderFunction; + onRenderCalloutPerStack?: RenderFunction; + // (undocumented) + optimizeLargeData?: boolean; + styles?: LineChartStyles; +} + +// @public +export interface LineChartStyleProps extends CartesianChartStyleProps { +} + +// @public +export interface LineChartStyles extends CartesianChartStyles { +} + +// @public (undocumented) +export interface LineDataInVerticalBarChart { + onClick?: VoidFunction; + useSecondaryYScale?: boolean; + // (undocumented) + y: VerticalBarChartDataPoint['y']; + // (undocumented) + yAxisCalloutData?: string | undefined; +} + +// @public (undocumented) +export interface LineDataInVerticalStackedBarChart { + // (undocumented) + color: string; + data?: number; + // (undocumented) + legend: string; + useSecondaryYScale?: boolean; + // (undocumented) + y: number; + // (undocumented) + yAxisCalloutData?: string; +} + +// @public (undocumented) +export interface Margins { + bottom?: number; + left?: number; + right?: number; + top?: number; +} + +// @public (undocumented) +export interface ModifiedCartesianChartProps extends CartesianChartProps { + barwidth?: number; + calloutProps?: ChartPopoverProps; + chartTitle?: string; + chartType: ChartTypes; + children(props: ChildProps): React_2.ReactNode; + culture?: string; + datasetForXAxisDomain?: string[]; + enableFirstRenderOptimization?: boolean; + // (undocumented) + getAxisData?: any; + getDomainMargins?: (containerWidth: number) => Margins; + getGraphData?: any; + getmargins?: (margins: Margins) => void; + legendBars: JSX.Element | null; + maxOfYVal?: number; + onChartMouseLeave?: () => void; + points: any; + showYAxisLables?: boolean; + showYAxisLablesTooltip?: boolean; + stringDatasetForYAxisDomain?: string[]; + tickParams?: { + tickValues?: number[] | Date[] | string[]; + tickFormat?: string; + }; + xAxisInnerPadding?: number; + xAxisOuterPadding?: number; + xAxisPadding?: number; + xAxisType: XAxisTypes; + yAxisPadding?: number; + yAxisType?: YAxisType; +} + +// @public (undocumented) +export interface PopoverComponentStyles { + // (undocumented) + calloutBlockContainer: string; + // (undocumented) + calloutBlockContainertoDrawShapefalse: string; + // (undocumented) + calloutBlockContainertoDrawShapetrue: string; + // (undocumented) + calloutContentRoot: string; + // (undocumented) + calloutContentX: string; + // (undocumented) + calloutContentY: string; + // (undocumented) + calloutDateTimeContainer: string; + // (undocumented) + calloutInfoContainer: string; + // (undocumented) + calloutlegendText: string; + // (undocumented) + denominator: string; + // (undocumented) + descriptionMessage: string; + // (undocumented) + numerator: string; + // (undocumented) + ratio: string; + // (undocumented) + shapeStyles: string; +} + +// @public (undocumented) +export interface RefArrayData { + // (undocumented) + index?: string; + // (undocumented) + refElement?: SVGGElement; +} + +// @public (undocumented) +export const Shape: React_2.FunctionComponent; + +// @public (undocumented) +export interface ShapeProps { + // (undocumented) + classNameForNonSvg?: string; + // (undocumented) + pathProps: React_2.SVGAttributes; + // (undocumented) + shape: LegendShape; + // (undocumented) + style?: React_2.CSSProperties | undefined; + // (undocumented) + svgProps: React_2.SVGAttributes; +} + +// @public +export const Sparkline: React_2.FunctionComponent; + +// @public +export interface SparklineProps extends React.RefAttributes { + className?: string; + culture?: string; + data?: ChartProps; + height?: number; + showLegend?: boolean; + styles?: SparklineStyles; + valueTextWidth?: number; + width?: number; +} + +// @public (undocumented) +export interface SparklineStyleProps extends CartesianChartStyleProps { +} + +// @public +export interface SparklineStyles { + // (undocumented) + inlineBlock?: string; + // (undocumented) + valueText?: string; +} + +// @public (undocumented) +export const Textbox: React_2.FunctionComponent; + +// @public +export const VerticalBarChart: React_2.FunctionComponent; + +// @public (undocumented) +export interface VerticalBarChartDataPoint { + callOutAccessibilityData?: AccessibilityProps; + color?: string; + legend?: string; + lineData?: LineDataInVerticalBarChart; + onClick?: VoidFunction; + x: number | string | Date; + xAxisCalloutData?: string; + y: number; + yAxisCalloutData?: string; +} + +// @public +export interface VerticalBarChartProps extends CartesianChartProps { + barWidth?: number | 'default' | 'auto'; + chartTitle?: string; + colors?: string[]; + culture?: string; + data?: VerticalBarChartDataPoint[]; + hideLabels?: boolean; + lineLegendColor?: string; + lineLegendText?: string; + lineOptions?: LineChartLineOptions; + maxBarWidth?: number; + onRenderCalloutPerDataPoint?: RenderFunction; + styles?: VerticalBarChartStyles; + useSingleColor?: boolean; + xAxisInnerPadding?: number; + xAxisOuterPadding?: number; + xAxisPadding?: number; +} + +// @public +export interface VerticalBarChartStyleProps extends CartesianChartStyleProps { + legendColor?: string; +} + +// @public +export interface VerticalBarChartStyles extends CartesianChartStyles { + barLabel: string; + // @deprecated + xAxisTicks?: string; + // @deprecated + yAxisDomain?: string; + // @deprecated + yAxisTicks?: string; +} + +// @public (undocumented) +export interface VerticalStackedBarDataPoint extends Omit { + x: number | string | Date; +} + +// @public (undocumented) +export interface VerticalStackedChartProps { + chartData: VSChartDataPoint[]; + lineData?: LineDataInVerticalStackedBarChart[]; + stackCallOutAccessibilityData?: AccessibilityProps; + xAxisCalloutData?: string; + xAxisPoint: number | string | Date; +} + +// @public (undocumented) +export interface VSChartDataPoint { + callOutAccessibilityData?: AccessibilityProps; + color?: string; + data: number; + legend: string; + xAxisCalloutData?: string; + yAxisCalloutData?: string; +} + +// @public (undocumented) +export interface YValueHover { + // (undocumented) + callOutAccessibilityData?: AccessibilityProps; + // (undocumented) + color?: string; + // (undocumented) + data?: string | number; + // (undocumented) + index?: number; + // (undocumented) + legend?: string; + // (undocumented) + shouldDrawBorderBottom?: boolean; + // (undocumented) + y?: number; + // (undocumented) + yAxisCalloutData?: string | { + [id: string]: number; + }; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/charts/react-charts-preview/library/jest.config.js b/packages/charts/react-charts-preview/library/jest.config.js new file mode 100644 index 0000000000000..417b629fc5dfa --- /dev/null +++ b/packages/charts/react-charts-preview/library/jest.config.js @@ -0,0 +1,34 @@ +// @ts-check + +/** + * @type {import('@jest/types').Config.InitialOptions} + */ +module.exports = { + displayName: 'react-charts-preview', + preset: '../../../../jest.preset.js', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + isolatedModules: true, + }, + ], + }, + coverageDirectory: './coverage', + setupFilesAfterEnv: ['./config/tests.js'], + snapshotSerializers: ['@griffel/jest-serializer'], + moduleNameMapper: { + '^d3-scale$': '/../../../../node_modules/d3-scale/dist/d3-scale.js', + '^d3-shape$': '/../../../../node_modules/d3-shape/dist/d3-shape.js', + '^d3-path$': '/../../../../node_modules/d3-path/dist/d3-path.js', + '^d3-array$': '/../../../../node_modules/d3-array/dist/d3-array.js', + '^d3-axis$': '/../../../../node_modules/d3-axis/dist/d3-axis.js', + '^d3-selection$': '/../../../../node_modules/d3-selection/dist/d3-selection.js', + '^d3-format$': '/../../../../node_modules/d3-format/dist/d3-format.js', + '^d3-time$': '/../../../../node_modules/d3-time/dist/d3-time.js', + '^d3-interpolate$': '/../../../../node_modules/d3-interpolate/dist/d3-interpolate.js', + '^d3-color$': '/../../../../node_modules/d3-color/dist/d3-color.js', + '^d3-hierarchy$': '/../../../../node_modules/d3-hierarchy/dist/d3-hierarchy.js', + }, +}; diff --git a/packages/charts/react-charts-preview/library/just.config.ts b/packages/charts/react-charts-preview/library/just.config.ts new file mode 100644 index 0000000000000..b7b2c9a33bf43 --- /dev/null +++ b/packages/charts/react-charts-preview/library/just.config.ts @@ -0,0 +1,5 @@ +import { preset, task } from '@fluentui/scripts-tasks'; + +preset(); + +task('build', 'build:react-components').cached?.(); diff --git a/packages/charts/react-charts-preview/library/package.json b/packages/charts/react-charts-preview/library/package.json new file mode 100644 index 0000000000000..5b1f9eda90d9d --- /dev/null +++ b/packages/charts/react-charts-preview/library/package.json @@ -0,0 +1,93 @@ +{ + "name": "@fluentui/react-charts-preview", + "version": "0.0.0", + "description": "React web chart controls for Microsoft fluentui v9 system.", + "main": "lib-commonjs/index.js", + "module": "lib/index.js", + "typings": "./dist/index.d.ts", + "sideEffects": false, + "files": [ + "*.md", + "dist/*.d.ts", + "lib", + "lib-commonjs" + ], + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui" + }, + "license": "MIT", + "scripts": { + "build": "just-scripts build", + "clean": "just-scripts clean", + "generate-api": "just-scripts generate-api", + "start": "yarn storybook", + "storybook": "yarn --cwd ../stories storybook", + "test": "jest --passWithNoTests", + "type-check": "just-scripts type-check" + }, + "syncpack": { + "dependencyTypes": [] + }, + "devDependencies": { + "@fluentui/eslint-plugin": "*", + "@fluentui/react-conformance": "*", + "@fluentui/react-conformance-griffel": "*", + "@fluentui/scripts-api-extractor": "*", + "@fluentui/scripts-tasks": "*" + }, + "dependencies": { + "@fluentui/react-button": "^9.3.92", + "@fluentui/react-jsx-runtime": "^9.0.44", + "@fluentui/react-overflow": "^9.1.30", + "@fluentui/react-popover": "^9.9.21", + "@fluentui/react-shared-contexts": "^9.20.1", + "@fluentui/react-tabster": "^9.22.7", + "@fluentui/react-theme": "^9.1.20", + "@fluentui/react-tooltip": "^9.4.39", + "@fluentui/react-utilities": "^9.18.15", + "@griffel/react": "^1.5.22", + "@swc/helpers": "^0.5.1", + "@types/d3-array": "^3.0.0", + "@types/d3-axis": "^3.0.0", + "@types/d3-format": "^3.0.0", + "@types/d3-hierarchy": "^3.0.0", + "@types/d3-sankey": "^0.12.3", + "@types/d3-scale": "^4.0.0", + "@types/d3-selection": "^3.0.0", + "@types/d3-shape": "^3.0.0", + "@types/d3-time": "^3.0.0", + "@types/d3-time-format": "^3.0.0", + "d3-array": "^3.0.0", + "d3-axis": "^3.0.0", + "d3-format": "^3.0.0", + "d3-hierarchy": "^3.0.0", + "d3-sankey": "^0.12.3", + "d3-scale": "^4.0.0", + "d3-selection": "^3.0.0", + "d3-shape": "^3.0.0", + "d3-time": "^3.0.0", + "d3-time-format": "^3.0.0" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <19.0.0", + "@types/react-dom": ">=16.9.0 <19.0.0", + "react": ">=16.14.0 <19.0.0", + "react-dom": ">=16.14.0 <19.0.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "node": "./lib-commonjs/index.js", + "import": "./lib/index.js", + "require": "./lib-commonjs/index.js" + }, + "./package.json": "./package.json" + }, + "beachball": { + "disallowedChangeTypes": [ + "major", + "prerelease" + ] + } +} diff --git a/packages/charts/react-charts-preview/library/project.json b/packages/charts/react-charts-preview/library/project.json new file mode 100644 index 0000000000000..e6396947463a4 --- /dev/null +++ b/packages/charts/react-charts-preview/library/project.json @@ -0,0 +1,8 @@ +{ + "name": "react-charts-preview", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "packages/charts/react-charts-preview/library/src", + "tags": ["platform:web", "vNext"], + "implicitDependencies": [] +} diff --git a/packages/charts/react-charts-preview/library/scripts/constants.js b/packages/charts/react-charts-preview/library/scripts/constants.js new file mode 100644 index 0000000000000..50d662aeaea5d --- /dev/null +++ b/packages/charts/react-charts-preview/library/scripts/constants.js @@ -0,0 +1,7 @@ +module.exports = { + Timezone: { + UTC: 'UTC', + IST: 'Asia/Kolkata', + Pacific: 'America/Los_Angeles', + }, +}; diff --git a/packages/charts/react-charts-preview/library/src/CartesianChart.ts b/packages/charts/react-charts-preview/library/src/CartesianChart.ts new file mode 100644 index 0000000000000..e3da5ee3dd987 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/CartesianChart.ts @@ -0,0 +1 @@ +export * from './components/CommonComponents/index'; diff --git a/packages/charts/react-charts-preview/library/src/DonutChart.ts b/packages/charts/react-charts-preview/library/src/DonutChart.ts new file mode 100644 index 0000000000000..5b791cc5402ec --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/DonutChart.ts @@ -0,0 +1 @@ +export * from './components/DonutChart/index'; diff --git a/packages/charts/react-charts-preview/library/src/HorizontalBarChart.ts b/packages/charts/react-charts-preview/library/src/HorizontalBarChart.ts new file mode 100644 index 0000000000000..25f19e9d313d4 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/HorizontalBarChart.ts @@ -0,0 +1 @@ +export * from './components/HorizontalBarChart/index'; diff --git a/packages/charts/react-charts-preview/library/src/Legends.ts b/packages/charts/react-charts-preview/library/src/Legends.ts new file mode 100644 index 0000000000000..d0b3c58ffecd2 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/Legends.ts @@ -0,0 +1 @@ +export * from './components/Legends/index'; diff --git a/packages/charts/react-charts-preview/library/src/LineChart.ts b/packages/charts/react-charts-preview/library/src/LineChart.ts new file mode 100644 index 0000000000000..2105ceffef3a5 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/LineChart.ts @@ -0,0 +1 @@ +export * from './components/LineChart/index'; diff --git a/packages/charts/react-charts-preview/library/src/Popover.ts b/packages/charts/react-charts-preview/library/src/Popover.ts new file mode 100644 index 0000000000000..e3da5ee3dd987 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/Popover.ts @@ -0,0 +1 @@ +export * from './components/CommonComponents/index'; diff --git a/packages/charts/react-charts-preview/library/src/Sparkline.ts b/packages/charts/react-charts-preview/library/src/Sparkline.ts new file mode 100644 index 0000000000000..9ff982be9d710 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/Sparkline.ts @@ -0,0 +1 @@ +export * from './components/Sparkline/index'; diff --git a/packages/charts/react-charts-preview/library/src/VerticalBarChart.ts b/packages/charts/react-charts-preview/library/src/VerticalBarChart.ts new file mode 100644 index 0000000000000..64e4264d310b9 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/VerticalBarChart.ts @@ -0,0 +1 @@ +export * from './components/VerticalBarChart/index'; diff --git a/packages/charts/react-charts-preview/library/src/components/CommonComponents/CartesianChart.tsx b/packages/charts/react-charts-preview/library/src/components/CommonComponents/CartesianChart.tsx new file mode 100644 index 0000000000000..5a2fd9918a69d --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/CommonComponents/CartesianChart.tsx @@ -0,0 +1,640 @@ +import * as React from 'react'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { ModifiedCartesianChartProps, HorizontalBarChartWithAxisDataPoint } from '../../index'; +import { useCartesianChartStyles_unstable } from './useCartesianChartStyles.styles'; +import { + createNumericXAxis, + createStringXAxis, + IAxisData, + getDomainNRangeValues, + createDateXAxis, + createYAxis, + createStringYAxis, + IMargins, + getMinMaxOfYAxis, + XAxisTypes, + YAxisType, + createWrapOfXLabels, + rotateXAxisLabels, + calculateLongestLabelWidth, + createYAxisLabels, + ChartTypes, + wrapContent, + useRtl, +} from '../../utilities/index'; +import { SVGTooltipText } from '../../utilities/SVGTooltipText'; +import { ChartPopover } from './ChartPopover'; +import { useFocusableGroup, useArrowNavigationGroup } from '@fluentui/react-tabster'; +import { ResponsiveContainer } from './ResponsiveContainer'; + +/** + * Cartesian Chart component + * {@docCategory CartesianChart} + */ +const CartesianChartBase: React.FunctionComponent = React.forwardRef< + HTMLDivElement, + ModifiedCartesianChartProps +>((props, forwardedRef) => { + const chartContainer = React.useRef(); + let legendContainer: HTMLDivElement; + const minLegendContainerHeight: number = 40; + const xAxisElement = React.useRef(); + const yAxisElement = React.useRef(); + const yAxisElementSecondary = React.useRef(); + let margins: IMargins; + const idForGraph: string = 'chart_'; + let _reqID: number; + const _useRtl: boolean = useRtl(); + let _tickValues: (string | number)[]; + const titleMargin: number = 8; + const _isFirstRender = React.useRef(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let _xScale: any; + let isIntegralDataset: boolean = true; + + const [containerWidth, setContainerWidth] = React.useState(0); + const [containerHeight, setContainerHeight] = React.useState(0); + const [isRemoveValCalculated, setIsRemoveValCalculated] = React.useState(true); + const [removalValueForTextTuncate, setRemovalValueForTextTuncate] = React.useState(0); + const [startFromX, setStartFromX] = React.useState(0); + const [prevProps, setPrevProps] = React.useState(null); + + /** + * In RTL mode, Only graph will be rendered left/right. We need to provide left and right margins manually. + * So that, in RTL, left margins becomes right margins and viceversa. + * As graph needs to be drawn perfecty, these values consider as default values. + * Same margins using for all other cartesian charts. Can be accessible through 'getMargins' call back method. + */ + // eslint-disable-next-line prefer-const + margins = { + top: props.margins?.top ?? 20, + bottom: props.margins?.bottom ?? 35, + right: _useRtl ? props.margins?.left ?? 40 : props.margins?.right ?? props?.secondaryYScaleOptions ? 40 : 20, + left: _useRtl ? (props.margins?.right ?? props?.secondaryYScaleOptions ? 40 : 20) : props.margins?.left ?? 40, + }; + if (props.xAxisTitle !== undefined && props.xAxisTitle !== '') { + margins.bottom! = props.margins?.bottom ?? 55; + } + if (props.yAxisTitle !== undefined && props.yAxisTitle !== '') { + margins.left! = _useRtl + ? props.margins?.right ?? props?.secondaryYAxistitle + ? 60 + : 40 + : props.margins?.left ?? 60; + margins.right! = _useRtl ? props.margins?.left ?? 60 : props.margins?.right ?? props?.secondaryYAxistitle ? 60 : 40; + } + + const classes = useCartesianChartStyles_unstable(props); + const focusAttributes = useFocusableGroup(); + const arrowAttributes = useArrowNavigationGroup({ axis: 'horizontal' }); + // ComponentdidMount and Componentwillunmount logic + React.useEffect(() => { + _fitParentContainer(); + if (props !== null) { + setPrevProps(props); + } + if (props.chartType === ChartTypes.HorizontalBarChartWithAxis && props.showYAxisLables && yAxisElement.current) { + const maxYAxisLabelLength = calculateLongestLabelWidth( + props.points.map((point: HorizontalBarChartWithAxisDataPoint) => point.y), + `.${classes.yAxis} text`, + ); + if (startFromX !== maxYAxisLabelLength) { + setStartFromX(maxYAxisLabelLength); + } + } else if (startFromX !== 0) { + setStartFromX(0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + isIntegralDataset = !props.points.some((point: { y: number }) => point.y % 1 !== 0); + return () => { + cancelAnimationFrame(_reqID); + }; + }, [props]); + + // ComponentDidUpdate logic + React.useEffect(() => { + if (prevProps) { + if (prevProps.height !== props.height || prevProps.width !== props.width) { + _fitParentContainer(); + } + } + if (props.chartType === ChartTypes.HorizontalBarChartWithAxis && props.showYAxisLables && yAxisElement.current) { + const maxYAxisLabelLength = calculateLongestLabelWidth( + props.points.map((point: HorizontalBarChartWithAxisDataPoint) => point.y), + `.${classes.yAxis} text`, + ); + if (startFromX !== maxYAxisLabelLength) { + setStartFromX(maxYAxisLabelLength); + } + } else if (startFromX !== 0) { + setStartFromX(0); + } + if (prevProps !== null && prevProps.points !== props.points) { + // eslint-disable-next-line react-hooks/exhaustive-deps + isIntegralDataset = !props.points.some((point: { y: number }) => point.y % 1 !== 0); + } + }, [props, prevProps]); + + React.useEffect(() => { + if (!props.wrapXAxisLables && props.rotateXAxisLables && props.xAxisType! === XAxisTypes.StringAxis) { + const rotateLabelProps = { + node: xAxisElement.current!, + xAxis: _xScale, + }; + const rotatedHeight = rotateXAxisLabels(rotateLabelProps); + + if ( + isRemoveValCalculated && + removalValueForTextTuncate !== rotatedHeight! + margins.bottom! && + rotatedHeight! > 0 + ) { + setRemovalValueForTextTuncate(rotatedHeight! + margins.bottom!); + setIsRemoveValCalculated(false); + } + } + }); + + /** + * 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. + * @param calloutProps + * @param chartHoverProps + * @returns + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function _generateCallout(calloutProps: any): JSX.Element { + return ; + } + + const { + calloutProps, + points, + chartType, + svgProps, + culture, + dateLocalizeOptions, + timeFormatLocale, + customDateTimeFormatter, + } = props; + if (props.parentRef) { + _fitParentContainer(); + } + const margin = { ...margins }; + if (props.chartType === ChartTypes.HorizontalBarChartWithAxis) { + if (!_useRtl) { + margin.left! += startFromX; + } else { + margin.right! += startFromX; + } + } + // Callback for margins to the chart + props.getmargins && props.getmargins(margin); + + let callout: JSX.Element | null = null; + + let children = null; + if ((props.enableFirstRenderOptimization && chartContainer.current) || !props.enableFirstRenderOptimization) { + _isFirstRender.current = false; + const XAxisParams = { + domainNRangeValues: getDomainNRangeValues( + points, + props.getDomainMargins ? props.getDomainMargins(containerWidth) : margins, + containerWidth, + chartType, + _useRtl, + props.xAxisType, + props.barwidth!, + props.tickValues!, + // This is only used for Horizontal Bar Chart with Axis for y as string axis + startFromX, + ), + containerHeight: containerHeight - removalValueForTextTuncate!, + margins: margins, + xAxisElement: xAxisElement.current!, + showRoundOffXTickValues: true, + xAxisCount: props.xAxisTickCount, + xAxistickSize: props.xAxistickSize, + tickPadding: props.tickPadding || props.showXAxisLablesTooltip ? 5 : 10, + xAxisPadding: props.xAxisPadding, + xAxisInnerPadding: props.xAxisInnerPadding, + xAxisOuterPadding: props.xAxisOuterPadding, + containerWidth: containerWidth, + hideTickOverlap: + props.hideTickOverlap && !props.rotateXAxisLables && !props.showXAxisLablesTooltip && !props.wrapXAxisLables, + }; + + const YAxisParams = { + margins: margins, + containerWidth: containerWidth, + containerHeight: containerHeight - removalValueForTextTuncate!, + yAxisElement: yAxisElement.current, + yAxisTickFormat: props.yAxisTickFormat!, + yAxisTickCount: props.yAxisTickCount!, + yMinValue: props.yMinValue || 0, + yMaxValue: props.yMaxValue || 0, + tickPadding: 10, + maxOfYVal: props.maxOfYVal, + yMinMaxValues: getMinMaxOfYAxis(points, chartType, props.yAxisType), + // please note these padding default values must be consistent in here + // and the parent chart(HBWA/Vertical etc..) for more details refer example + // http://using-d3js.com/04_07_ordinal_scales.html + yAxisPadding: props.yAxisPadding || 0, + }; + /** + * These scales used for 2 purposes. + * 1. To create x and y axis + * 2. To draw the graph. + * For area/line chart using same scales. For other charts, creating their own scales to draw the graph. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let xScale: any; + let tickValues: (string | number)[]; + switch (props.xAxisType!) { + case XAxisTypes.NumericAxis: + ({ xScale, tickValues } = createNumericXAxis(XAxisParams, props.tickParams!, props.chartType, culture)); + break; + case XAxisTypes.DateAxis: + ({ xScale, tickValues } = createDateXAxis( + XAxisParams, + props.tickParams!, + culture, + dateLocalizeOptions, + timeFormatLocale, + customDateTimeFormatter, + props.useUTC, + )); + break; + case XAxisTypes.StringAxis: + ({ xScale, tickValues } = createStringXAxis( + XAxisParams, + props.tickParams!, + props.datasetForXAxisDomain!, + culture, + )); + break; + default: + ({ xScale, tickValues } = createNumericXAxis(XAxisParams, props.tickParams!, props.chartType, culture)); + } + _xScale = xScale; + _tickValues = tickValues; + + /* + * To enable wrapping of x axis tick values or to display complete x axis tick values, + * we need to calculate how much space it needed to render the text. + * No need to re-calculate every time the chart renders and same time need to get an update. So using set + * Required space will be calculated first time chart rendering and if any width/height of chart updated. + * */ + if (props.wrapXAxisLables || props.showXAxisLablesTooltip) { + const wrapLabelProps = { + node: xAxisElement.current!, + xAxis: xScale, + showXAxisLablesTooltip: props.showXAxisLablesTooltip || false, + noOfCharsToTruncate: props.noOfCharsToTruncate || 4, + }; + const temp = xScale && (createWrapOfXLabels(wrapLabelProps) as number); + // this value need to be updated for draw graph updated. So instead of using private value, using set + if (isRemoveValCalculated && removalValueForTextTuncate !== temp) { + setRemovalValueForTextTuncate(temp); + setIsRemoveValCalculated(false); + } + } + /** + * These scales used for 2 purposes. + * 1. To create x and y axis + * 2. To draw the graph. + * For area/line chart using same scales. For other charts, creating their own scales to draw the graph. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let yScale: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let yScaleSecondary: any; + const axisData: IAxisData = { yAxisDomainValues: [] }; + if (props.yAxisType && props.yAxisType === YAxisType.StringAxis) { + yScale = createStringYAxis( + YAxisParams, + props.stringDatasetForYAxisDomain!, + _useRtl, + props.chartType, + props.barwidth, + culture, + ); + } else { + if (props?.secondaryYScaleOptions) { + const YAxisParamsSecondary = { + margins: margins, + containerWidth: containerWidth, + containerHeight: containerHeight - removalValueForTextTuncate!, + yAxisElement: yAxisElementSecondary.current, + yAxisTickFormat: props.yAxisTickFormat!, + yAxisTickCount: props.yAxisTickCount!, + yMinValue: props.secondaryYScaleOptions?.yMinValue || 0, + yMaxValue: props.secondaryYScaleOptions?.yMaxValue ?? 100, + tickPadding: 10, + maxOfYVal: props.secondaryYScaleOptions?.yMaxValue ?? 100, + yMinMaxValues: getMinMaxOfYAxis(points, chartType), + yAxisPadding: props.yAxisPadding, + }; + + yScaleSecondary = createYAxis( + YAxisParamsSecondary, + _useRtl, + axisData, + chartType, + props.barwidth!, + isIntegralDataset, + true, + ); + } + yScale = createYAxis(YAxisParams, _useRtl, axisData, chartType, props.barwidth!, isIntegralDataset); + } + + /* + * To create y axis tick values by if specified + truncating the rest of the text and showing elipsis + or showing the whole string, + * */ + props.chartType === ChartTypes.HorizontalBarChartWithAxis && + yScale && + createYAxisLabels( + yAxisElement.current!, + yScale, + props.noOfCharsToTruncate || 4, + props.showYAxisLablesTooltip || false, + startFromX, + _useRtl, + ); + + // Call back to the chart. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const _getData = (xScale: any, yScale: any) => { + props.getGraphData && + props.getGraphData( + xScale, + yScale, + containerHeight - removalValueForTextTuncate!, + containerWidth, + xAxisElement.current, + yAxisElement.current, + ); + }; + + props.getAxisData && props.getAxisData(axisData); + // Callback function for chart, returns axis + _getData(xScale, yScale); + + children = props.children({ + xScale, + yScale, + yScaleSecondary, + containerHeight, + containerWidth, + }); + + if (!props.hideTooltip && calloutProps!.isPopoverOpen) { + callout = _generateCallout(calloutProps); + } + } + const svgDimensions = { + width: containerWidth, + height: containerHeight, + }; + + const xAxisTitleMaximumAllowedWidth = svgDimensions.width - margins.left! - margins.right! - startFromX!; + const yAxisTitleMaximumAllowedHeight = + svgDimensions.height - margins.bottom! - margins.top! - removalValueForTextTuncate! - titleMargin; + /** + * When screen resizes, along with screen, chart also auto adjusted. + * This method used to adjust height and width of the charts. + */ + function _fitParentContainer(): void { + //_reqID = requestAnimationFrame(() => { + let legendContainerHeight; + if (props.hideLegend) { + // If there is no legend, need not to allocate some space from total chart space. + legendContainerHeight = 0; + } else { + const legendContainerComputedStyles = legendContainer && getComputedStyle(legendContainer); + legendContainerHeight = + ((legendContainer && legendContainer.getBoundingClientRect().height) || minLegendContainerHeight) + + parseFloat((legendContainerComputedStyles && legendContainerComputedStyles.marginTop) || '0') + + parseFloat((legendContainerComputedStyles && legendContainerComputedStyles.marginBottom) || '0'); + } + if (props.parentRef || chartContainer.current) { + const container = props.parentRef ? props.parentRef : chartContainer.current!; + const currentContainerWidth = + props.enableReflow && !_isFirstRender.current + ? Math.max(container.getBoundingClientRect().width, _calculateChartMinWidth()) + : container.getBoundingClientRect().width; + const currentContainerHeight = + container.getBoundingClientRect().height > legendContainerHeight + ? container.getBoundingClientRect().height + : 350; + const shouldResize = + containerWidth !== currentContainerWidth || containerHeight !== currentContainerHeight - legendContainerHeight; + if (shouldResize) { + setContainerWidth(currentContainerWidth); + setContainerHeight(currentContainerHeight - legendContainerHeight); + } + } + //}); + } + + function _onChartLeave(): void { + props.onChartMouseLeave && props.onChartMouseLeave(); + } + + function _calculateChartMinWidth(): number { + let labelWidth = 10; // Total padding on the left and right sides of the label + + // Case: rotated labels + if (!props.wrapXAxisLables && props.rotateXAxisLables && props.xAxisType! === XAxisTypes.StringAxis) { + const longestLabelWidth = calculateLongestLabelWidth(_tickValues, `.${classes.xAxis} text`); + labelWidth += Math.ceil(longestLabelWidth * Math.cos(Math.PI / 4)); + } + // Case: truncated labels + else if (props.showXAxisLablesTooltip) { + const tickValues = _tickValues.map(val => { + const numChars = props.noOfCharsToTruncate || 4; + return val.toString().length > numChars ? `${val.toString().slice(0, numChars)}...` : val; + }); + + const longestLabelWidth = calculateLongestLabelWidth(tickValues, `.${classes.xAxis} text`); + labelWidth += Math.ceil(longestLabelWidth); + } + // Case: wrapped labels + else if (props.wrapXAxisLables) { + const words: string[] = []; + _tickValues.forEach((val: string) => { + words.push(...val.toString().split(/\s+/)); + }); + + const longestLabelWidth = calculateLongestLabelWidth(words, `.${classes.xAxis} text`); + labelWidth += Math.max(Math.ceil(longestLabelWidth), 10); + } + // Default case + else { + const longestLabelWidth = calculateLongestLabelWidth(_tickValues, `.${classes.xAxis} text`); + labelWidth += Math.ceil(longestLabelWidth); + } + + let minChartWidth = margins.left! + margins.right! + labelWidth * (_tickValues.length - 1); + + if ( + [ChartTypes.GroupedVerticalBarChart, ChartTypes.VerticalBarChart, ChartTypes.VerticalStackedBarChart].includes( + props.chartType, + ) + ) { + const minDomainMargin = 8; + minChartWidth += minDomainMargin * 2; + } + + return minChartWidth; + } + + /** + * We have use the {@link defaultTabbableElement } to fix + * the Focus not landing on chart while tabbing, instead goes to legend. + * This issue is observed in Area, line chart after performance optimization done in the PR {@link https://github.com/microsoft/fluentui/pull/27721 } + * This issue is observed in Bar charts after the changes done by FocusZone team in the PR: {@link https://github.com/microsoft/fluentui/pull/24175 } + * The issue in Bar Charts(VB and VSB) is due to a {@link FocusZone } update where previously an event listener was + * attached on keydown to the window, so that whenever the tab key is pressed all outer FocusZone's + * tab-indexes are updated (an outer FocusZone is a FocusZone that is not within another one). + * But now after the above PR : they are attaching the + * listeners to the FocusZone elements instead of the window. So in the first render cycle in Bar charts + * bars are not created as in the first render cycle the size of the chart container is not known( or is 0) + * which creates bars of height 0 so instead we do not create any bars and instead return empty fragments. + * + * We have tried 2 Approaches to fix the issue: + * 1. Using the {@link elementRef} property of FocusZone where we dispatch event for tab keydown + * after the second render cycle which triggers an update of the tab index in FocusZone. + * But this is a hacky solution and not a proper fix and also elementRef is deprecated. + * 2. Using the default tabbable element to fix the issue. + */ + + return ( +
(chartContainer.current = rootElem)} + onMouseLeave={_onChartLeave} + > +
+ {_isFirstRender.current} + + { + xAxisElement.current = e!; + }} + id={`xAxisGElement${idForGraph}`} + // To add wrap of x axis lables feature, need to remove word height from svg height. + transform={`translate(0, ${svgDimensions.height - margins.bottom! - removalValueForTextTuncate!})`} + className={classes.xAxis} + /> + {props.xAxisTitle !== undefined && props.xAxisTitle !== '' && ( + + )} + { + yAxisElement.current = e!; + }} + id={`yAxisGElement${idForGraph}`} + transform={`translate(${ + _useRtl ? svgDimensions.width - margins.right! - startFromX : margins.left! + startFromX + }, 0)`} + className={classes.yAxis} + /> + {props.secondaryYScaleOptions && ( + + { + yAxisElementSecondary.current = e!; + }} + id={`yAxisGElementSecondary${idForGraph}`} + transform={`translate(${_useRtl ? margins.left! : svgDimensions.width - margins.right!}, 0)`} + className={classes.yAxis} + /> + {props.secondaryYAxistitle !== undefined && props.secondaryYAxistitle !== '' && ( + + )} + + )} + {children} + {props.yAxisTitle !== undefined && props.yAxisTitle !== '' && ( + + )} + +
+ + {!props.hideLegend && ( +
(legendContainer = e)} className={classes.legendContainer}> + {props.legendBars} +
+ )} + {/** The callout is used for narration, so keep it mounted on the DOM */} + {callout && Loading...
}>{callout}} + + ); +}); + +export const CartesianChart: React.FunctionComponent = props => { + if (!props.responsive) { + return ; + } + + return ( + + {({ containerWidth, containerHeight }) => ( + + )} + + ); +}; +CartesianChart.displayName = 'CartesianChart'; +CartesianChart.defaultProps = { + responsive: true, +}; diff --git a/packages/charts/react-charts-preview/library/src/components/CommonComponents/CartesianChart.types.ts b/packages/charts/react-charts-preview/library/src/components/CommonComponents/CartesianChart.types.ts new file mode 100644 index 0000000000000..8d1529126399c --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/CommonComponents/CartesianChart.types.ts @@ -0,0 +1,598 @@ +import * as React from 'react'; +import { LegendsProps } from '../Legends/index'; +import { AccessibilityProps, Margins } from '../../types/index'; +import { ChartTypes, XAxisTypes, YAxisType } from '../../utilities/index'; +import { TimeLocaleDefinition } from 'd3-time-format'; +import { ChartPopoverProps } from './ChartPopover.types'; +/** + * Cartesian Chart style properties + * {@docCategory CartesianChart} + */ +export interface CartesianChartStyleProps { + /** + * Additional CSS class(es) to apply to the Chart. + */ + className?: string; + + /** + * Width of the chart. + */ + width?: number; + + /** + * Height of the chart. + */ + height?: number; + + /** + * Color of the chart. + */ + color?: string; + + /** + * Link to redirect if click action for graph + */ + href?: string; + + /** + * prop to check if the chart is selected or hovered upon to determine opacity + */ + shouldHighlight?: boolean; + + /** + * prop to check if the Page is in Rtl + */ + useRtl?: boolean; + + /** + * color of the line + */ + lineColor?: string; + + /** + * boolean flag which determines if shape is drawn in callout + */ + toDrawShape?: boolean; +} + +/** + * Cartesian Chart styles + * {@docCategory CartesianChart} + */ +export interface CartesianChartStyles { + /** + * Style for the root element. + */ + root?: string; + + /** + * Style for the element containing the x-axis. + */ + xAxis?: string; + + /** + * Style for the element containing the y-axis. + */ + yAxis?: string; + + /** + * Style for legend container + */ + legendContainer?: string; + + /** + * line hover box css + */ + hover?: string; + + /** + * styles for callout root-content + */ + calloutContentRoot?: string; + + /** + * styles for callout x-content + */ + calloutContentX?: string; + + /** + * styles for callout y-content + */ + calloutContentY?: string; + + /** + * styles for description message + */ + descriptionMessage?: string; + + /** + * styles for callout Date time container + */ + calloutDateTimeContainer?: string; + + /** + * styles for callout info container + */ + calloutInfoContainer?: string; + + /** + * styles for callout block container + */ + calloutBlockContainer?: string; + + /** + * Styles for callout block container when toDrawShape is false + */ + calloutBlockContainertoDrawShapefalse?: string; + + /** + * Styles for callout block container when toDrawShape is true + */ + calloutBlockContainertoDrawShapetrue?: string; + + /** + * styles for callout legend text + */ + calloutlegendText?: string; + + /** + * styles for tooltip + */ + tooltip?: string; + + /** + * styles for tooltip + */ + axisTitle?: string; + + /** + * Style for the chart Title. + */ + chartTitle?: string; + + /** + * Style to change the opacity of bars in dataviz when we hover on a single bar or legends + */ + opacityChangeOnHover?: string; + + /** + * styles for the shape object in the callout + */ + shapeStyles?: string; + + /** + * Styles for the chart wrapper div + */ + chartWrapper?: string; +} + +/** + * Cartesian Chart properties + * {@docCategory CartesianChart} + */ +export interface CartesianChartProps { + /** + * Below height used for resizing of the chart + * Wrap chart in your container and send the updated height and width to these props. + * These values decide wheather chart re render or not. Please check examples for reference + */ + height?: number; + + /** + * Below width used for resizing of the chart + * Wrap chart in your container and send the updated height and width to these props. + * These values decide wheather chart re render or not. Please check examples for reference + */ + width?: number; + + /** + * this prop takes its parent as a HTML element to define the width and height of the chart + */ + parentRef?: HTMLElement | null; + + /** + * Additional CSS class(es) to apply to the Chart. + */ + className?: string; + + /** + * Margins for the chart + * @default `{ top: 20, bottom: 35, left: 40, right: 20 }` + * To avoid edge cuttings to the chart, we recommend you use default values or greater then default values + */ + margins?: Margins; + + /** decides wether to show/hide legends + * @defaultvalue false + */ + hideLegend?: boolean; + + /** + * Do not show tooltips in chart + * @default false + */ + hideTooltip?: boolean; + + /** + * this prop takes values that you want the chart to render on x-axis + * This is a optional parameter if not specified D3 will decide which values appear on the x-axis for you + * Please look at https://github.com/d3/d3-scale for more information on how D3 decides what data to appear on the axis of chart + */ + tickValues?: number[] | Date[] | string[] | undefined; + + /** + * the format for the data on x-axis. For date object this can be specified to your requirement. Eg: '%m/%d', '%d' + * Please look at https://github.com/d3/d3-time-format for all the formats supported for date axis + * Only applicable for date axis. For y-axis format use yAxisTickFormat prop. + */ + tickFormat?: string; + + /** + * Width of line stroke + */ + strokeWidth?: number; + + /** + * x Axis labels tick padding. This defines the gap between tick labels and tick lines. + * @default 10 + */ + xAxisTickPadding?: number; + + /** + * the format in for the data on y-axis. For data object this can be specified to your requirement. + * Eg: d3.format(".0%")(0.123),d3.format("+20")(42); + * Please look at https://github.com/d3/d3-format for all the formats supported + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + yAxisTickFormat?: any; + + /** + * Secondary y-scale options + * By default this is not defined, meaning there will be no secondary y-scale. + */ + secondaryYScaleOptions?: { + /** Minimum value (0 by default) */ + yMinValue?: number; + /** Maximum value (100 by default) */ + yMaxValue?: number; + }; + + /** + * minimum data value point in y-axis + */ + yMinValue?: number; + + /** + * maximum data value point in y-axis + */ + yMaxValue?: number; + + /** + * maximum data value point in x-axis + */ + xMaxValue?: number; + + /** + * Number of ticks on the y-axis. + * Tick count should be factor of difference between (yMinValue, yMaxValue)? + * @default 4 + */ + yAxisTickCount?: number; + + /** + * defines the number of ticks on the x-axis. Tries to match the nearest interval satisfying the count. + * Does not work for string axis. + * @default 6 + */ + xAxisTickCount?: number; + + /** + * define the size of the tick lines on the x-axis + * @default 10 + */ + xAxistickSize?: number; + + /** + * defines the space between the tick line and the data label + * @default 10 + */ + tickPadding?: number; + + /** + * Url that the data-viz needs to redirect to upon clicking on it + */ + href?: string; + + /** + * Label to apply to the whole chart. + * @deprecated - Use your chart label for the chart. + */ + chartLabel?: string; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + legendsOverflowText?: any; + + /** + * Enable the legends to wrap lines if there is not enough space to show all legends on a single line + */ + enabledLegendsWrapLines?: boolean; + + /* + * props for the legends in the chart + */ + legendProps?: Partial; + + /** + *@default false + *Used for to elipse x axis labes and show tooltip on x axis labels + */ + showXAxisLablesTooltip?: boolean; + + /** + * @default 4 + * Used for X axis labels + * While Giving showXAxisLablesTooltip prop, need to define after how many chars, we need to truncate the word. + */ + noOfCharsToTruncate?: number; + + /** + * @default false + * Used to wrap x axis labels values (whole value) + */ + wrapXAxisLables?: boolean; + + /** + * @default false + * Used to rotate x axis labels by 45 degrees + */ + rotateXAxisLables?: boolean; + + /** + * The prop used to define the date time localization options + */ + dateLocalizeOptions?: Intl.DateTimeFormatOptions; + + /** + * The prop used to define a custom locale for the date time format. + */ + timeFormatLocale?: TimeLocaleDefinition; + + /** + * The prop used to define a custom datetime formatter for date axis. + */ + customDateTimeFormatter?: (dateTime: Date) => string; + + /** + * Call to provide customized styling that will layer on top of the variant rules. + */ + styles?: CartesianChartStyles; + + /** + * Callout customization props + */ + calloutProps?: Partial; + + /** + * props for the svg; use this to include aria-* or other attributes on the tag + */ + svgProps?: React.SVGProps; + + /** + * Prop to disable shrinking of the chart beyond a certain limit and enable scrolling when the chart overflows + * @default True for LineChart but False for other charts + */ + enableReflow?: boolean; + + /** + * Prop to set the x axis title + * @default undefined + * Minimum bottom margin required for x axis title is 55px + */ + + xAxisTitle?: string; + + /** + * Prop to set the y axis title + * @default undefined + * Minimum left margin required for y axis title is 60px and for RTL is 40px + * Minimum right margin required for y axis title is 40px and for RTL is 60px + */ + yAxisTitle?: string; + + /** + * Prop to set the secondary y axis title + * @default undefined + * If RTL is enabled, minimum left and right margins required for secondary y axis title is 60px + */ + secondaryYAxistitle?: string; + + /** + * Whether to use UTC time for axis scale, ticks, and the time display in callouts. + * When set to `true`, time is displayed equally, regardless of the user's timezone settings. + * @default true + */ + useUTC?: string | boolean; + + /** + * Enables the chart to automatically adjust its size based on the container's dimensions. + * @default true + */ + responsive?: boolean; + + /** + * The function that is called when the chart is resized. + * @param width - The new width of the chart. + * @param height - The new height of the chart. + */ + onResize?: (width: number, height: number) => void; + + /** + * Determines whether overlapping x-axis tick labels should be hidden. + * @default false + */ + hideTickOverlap?: boolean; + + /** + * Define a custom callout props override + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + customProps?: (dataPointCalloutProps: any) => ChartPopoverProps; +} + +export interface YValueHover { + legend?: string; + y?: number; + color?: string; + data?: string | number; + shouldDrawBorderBottom?: boolean; + yAxisCalloutData?: string | { [id: string]: number }; + index?: number; + callOutAccessibilityData?: AccessibilityProps; +} + +export interface ChildProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + xScale?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + yScale?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + yScaleSecondary?: any; + containerHeight?: number; + containerWidth?: number; + optimizeLargeData?: boolean; +} + +// Only used for Cartesian chart base +export interface ModifiedCartesianChartProps extends CartesianChartProps { + /** + * Define the chart title + */ + chartTitle?: string; + + /** + * Only used for Area chart + * Value used to draw y axis of that chart. + */ + maxOfYVal?: number; + + /** + * Data of the chart + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + points: any; + + /** + * Define type of the chart + */ + chartType: ChartTypes; + + /** X axis type */ + xAxisType: XAxisTypes; + + /** Y axis type */ + yAxisType?: YAxisType; + + /** + * Legends of the chart. + */ + legendBars: JSX.Element | null; + + /** + * Callout props + */ + calloutProps?: ChartPopoverProps; + + /** + * Callback method used for to get margins to the chart. + */ + getmargins?: (margins: Margins) => void; + + /** + * This is a call back method to the chart from cartesian chart. + * params are xScale, yScale, containerHeight, containerWidth. These values were used to draw the graph. + * It also contians an optional param xAxisElement - defines as x axis scale element. + * This param used to enable feature word wrap of Xaxis. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getGraphData?: any; + + /** + * Used for bar chart graphs. + * To define width of the bar + */ + barwidth?: number; + + /** + * Used for tick styles of the x axis of the chart + * Tick params are applicable for date axis only. + */ + tickParams?: { + tickValues?: number[] | Date[] | string[]; + tickFormat?: string; + }; + + /** + * it's padding between bar's or lines in the graph + */ + xAxisPadding?: number; + + /** + * it's padding between bar's or lines in the graph + */ + yAxisPadding?: number; + + /** + * Children elements specific to derived chart types. + */ + children(props: ChildProps): React.ReactNode; + + /** dataset values to find out domain of the String axis + * Present using for only vertical stacked bar chart and grouped vertical bar chart + */ + datasetForXAxisDomain?: string[]; + + /** + * if the data points for the y-axis is of type string, then we need to give this + * prop to construct the y-axis + */ + stringDatasetForYAxisDomain?: string[]; + + /** + * The prop used to define the culture to localize the numbers and date + */ + culture?: string; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAxisData?: any; + + /** + * Callback method used when mouse leaves the chart boundary. + */ + onChartMouseLeave?: () => void; + + /** Callback method to get extra margins for domain */ + getDomainMargins?: (containerWidth: number) => Margins; + + /** Padding between each bar/line-point */ + xAxisInnerPadding?: number; + + /** Padding before first bar/line-point and after last bar/line-point */ + xAxisOuterPadding?: number; + + /** + *@default false + *Used for to elipse y axis labes and show tooltip on x axis labels + */ + showYAxisLablesTooltip?: boolean; + + /** + *@default false + *Used for showing complete y axis lables */ + showYAxisLables?: boolean; + + /** + * @default false + * Used to control the first render cycle Performance optimization code. + */ + enableFirstRenderOptimization?: boolean; +} diff --git a/packages/charts/react-charts-preview/library/src/components/CommonComponents/ChartPopover.tsx b/packages/charts/react-charts-preview/library/src/components/CommonComponents/ChartPopover.tsx new file mode 100644 index 0000000000000..33060c52f36ab --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/CommonComponents/ChartPopover.tsx @@ -0,0 +1,248 @@ +import * as React from 'react'; +import { Popover, PopoverSurface } from '@fluentui/react-popover'; +import { mergeClasses } from '@griffel/react'; +import type { PositioningVirtualElement } from '@fluentui/react-positioning'; +import { tokens } from '@fluentui/react-theme'; +import { useId } from '@fluentui/react-utilities'; +import { getAccessibleDataObject, Points, pointTypes } from '../../utilities/index'; +import { convertToLocaleString } from '../../utilities/locale-util'; +import { Shape } from '../Legends/shape'; +import { usePopoverStyles_unstable } from './useChartPopoverStyles.styles'; +import { YValueHover } from './CartesianChart.types'; +import { LegendShape } from '../Legends/Legends.types'; +import { ChartPopoverProps } from './ChartPopover.types'; + +/* This component is a wrapper over Popover component which implements the logic for rendering popovers for any chart +combining the logic for Callout and ChartHoverCard in v8 charts. */ +export const ChartPopover: React.FunctionComponent = React.forwardRef< + HTMLDivElement, + ChartPopoverProps +>((props, forwardedRef) => { + const virtualElement: PositioningVirtualElement = { + getBoundingClientRect: () => ({ + top: props.clickPosition!.y, + left: props.clickPosition!.x, + right: props.clickPosition!.x, + bottom: props.clickPosition!.y, + x: props.clickPosition!.x, + y: props.clickPosition!.y, + width: 0, + height: 0, + }), + }; + props = { ...props, ...props.customCallout?.customCalloutProps }; + const classes = usePopoverStyles_unstable(props); + const legend = props.xCalloutValue ? props.xCalloutValue : props.legend; + const YValue = props.yCalloutValue ? props.yCalloutValue : props.YValue; + return ( +
+ + + {/** Given custom callout, then it will render */} + {props.customCallout && props.customCallout.customizedCallout && props.customCallout.customizedCallout} + {/** single x point its corresponding y points of all the bars/lines in chart will render in callout */} + {(!props.customCallout || !props.customCallout.customizedCallout) && + props.isCalloutForStack && + _multiValueCallout()} + {/** single x point its corresponding y point of single line/bar in the chart will render in callout */} + {(!props.customCallout || !props.customCallout.customizedCallout) && !props.isCalloutForStack && ( +
+
+
{props.XValue}
+ {/*TO DO if we add time for callout then will use this */} + {/*
07:00am
*/} +
+
+
+
{convertToLocaleString(legend, props.culture)}
+
+ {convertToLocaleString(YValue, props.culture)} +
+
+ {!!props.ratio && ( +
+ <> + {convertToLocaleString(props.ratio[0], props.culture)}/ + + {convertToLocaleString(props.ratio[1], props.culture)} + + +
+ )} +
+ {!!props.descriptionMessage && ( +
{props.descriptionMessage}
+ )} +
+ )} +
+
+
+ ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function _multiValueCallout() { + const yValueHoverSubCountsExists: boolean = _yValueHoverSubCountsExists(props.YValueHover) ?? false; + return ( +
+
+
+ {convertToLocaleString(props!.hoverXValue, props.culture)} +
+
+
+ {props!.YValueHover && + props!.YValueHover.map((yValue: YValueHover, index: number, yValues: YValueHover[]) => { + const isLast: boolean = index + 1 === yValues.length; + const { shouldDrawBorderBottom = false } = yValue; + return ( +
+ {_getCalloutContent(yValue, index, yValueHoverSubCountsExists, isLast)} +
+ ); + })} + {!!props.descriptionMessage &&
{props.descriptionMessage}
} +
+
+ ); + } + + function _yValueHoverSubCountsExists(yValueHover?: YValueHover[]): boolean | undefined { + return ( + yValueHover && + yValueHover.some( + (yValue: { + legend?: string; + y?: number; + color?: string; + yAxisCalloutData?: string | { [id: string]: number }; + }) => yValue.yAxisCalloutData && typeof yValue.yAxisCalloutData !== 'string', + ) + ); + } + + function _getCalloutContent( + xValue: YValueHover, + index: number, + yValueHoverSubCountsExists: boolean, + isLast: boolean, + ): React.ReactNode { + const marginStyle: React.CSSProperties = isLast ? {} : { marginRight: '16px' }; + const toDrawShape = xValue.index !== undefined && xValue.index !== -1; + const { culture } = props; + const yValue = convertToLocaleString(xValue.y, culture); + if (!xValue.yAxisCalloutData || typeof xValue.yAxisCalloutData === 'string') { + return ( +
+ {yValueHoverSubCountsExists && ( +
+ {xValue.legend!} ({yValue}) +
+ )} +
+ {toDrawShape && ( + + )} +
+
{xValue.legend}
+
+ {convertToLocaleString( + xValue.yAxisCalloutData ? xValue.yAxisCalloutData : xValue.y ?? xValue.data, + culture, + )} +
+
+
+
+ ); + } else { + const subcounts: { [id: string]: number } = xValue.yAxisCalloutData as { [id: string]: number }; + return ( +
+
+ {xValue.legend!} ({yValue}) +
+ {Object.keys(subcounts).map((subcountName: string) => { + return ( +
+
{convertToLocaleString(subcountName, culture)}
+
+ {convertToLocaleString(subcounts[subcountName], culture)} +
+
+ ); + })} +
+ ); + } + } +}); +ChartPopover.displayName = 'ChartPopover'; diff --git a/packages/charts/react-charts-preview/library/src/components/CommonComponents/ChartPopover.types.ts b/packages/charts/react-charts-preview/library/src/components/CommonComponents/ChartPopover.types.ts new file mode 100644 index 0000000000000..c63a0e7a300e0 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/CommonComponents/ChartPopover.types.ts @@ -0,0 +1,41 @@ +import { YValueHover } from '../../index'; + +export interface ChartPopoverProps { + clickPosition?: { x: number; y: number }; + isPopoverOpen?: boolean; + xCalloutValue?: string; + legend?: string | number | Date; + yCalloutValue?: string; + YValue?: string | number | Date; + XValue?: string; + color?: string; + culture?: string; + customCallout?: { + customizedCallout?: JSX.Element; + customCalloutProps?: ChartPopoverProps; + }; + isCalloutForStack?: boolean; + xAxisCalloutAccessibilityData?: { ariaLabel?: string; data?: string }; + hoverXValue?: string | number; + YValueHover?: YValueHover[]; + descriptionMessage?: string; + ratio?: [number, number]; + isCartesian?: boolean; +} + +export interface PopoverComponentStyles { + calloutContentRoot: string; + calloutDateTimeContainer: string; + calloutContentX: string; + calloutBlockContainer: string; + calloutBlockContainertoDrawShapefalse: string; + calloutBlockContainertoDrawShapetrue: string; + shapeStyles: string; + calloutlegendText: string; + calloutContentY: string; + descriptionMessage: string; + ratio: string; + numerator: string; + denominator: string; + calloutInfoContainer: string; +} diff --git a/packages/charts/react-charts-preview/library/src/components/CommonComponents/PopoverRTL.test.tsx b/packages/charts/react-charts-preview/library/src/components/CommonComponents/PopoverRTL.test.tsx new file mode 100644 index 0000000000000..40fa11000f20f --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/CommonComponents/PopoverRTL.test.tsx @@ -0,0 +1,137 @@ +import * as React from 'react'; +import { ChartPopover } from './ChartPopover'; +import { getByClass, getById } from '../../utilities/TestUtility.test'; +import { act, getByText, render } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; + +expect.extend(toHaveNoViolations); + +beforeAll(() => { + // https://github.com/jsdom/jsdom/issues/3368 + global.ResizeObserver = class ResizeObserver { + public observe() { + // do nothing + } + public unobserve() { + // do nothing + } + public disconnect() { + // do nothing + } + }; +}); + +describe('Popover', () => { + test('renders the popover component', () => { + const { container } = render(); + const popoverElement = getById(container, /callout1/); + expect(popoverElement).toBeDefined(); + }); + + test('displays the correct XValue', () => { + const XValue = 'Sample XValue'; + const { container } = render(); + const XValueElement = getByText(container, /Sample XValue/); + expect(XValueElement).toBeDefined(); + expect(XValueElement.textContent?.trim()).toBe(XValue); + }); + + test('displays the popover card correctly when XValue is undefined', () => { + const XValue = undefined; + const { container } = render(); + const XValueElement = getByClass(container, /calloutContentX/)[0] as HTMLElement; + expect(XValueElement.children[0]).toBeUndefined(); + }); + + test('displays the correct YValue', () => { + const YValue = 'Sample YValue'; + const { container } = render(); + const YValueElement = getByText(container, /Sample YValue/); + expect(YValueElement).toBeDefined(); + expect(YValueElement.textContent?.trim()).toBe(YValue); + }); + + test('displays the popover card correctly when YValue is undefined', () => { + const YValue = undefined; + const { container } = render(); + const YValueElement = getByClass(container, /calloutContentY/)[0] as HTMLElement; + expect(YValueElement.children[0]).toBeUndefined(); + }); + + test('displays the correct YValue when YValue is a number', () => { + const YValue = 123; + const { container } = render(); + const YValueElement = getByText(container, /123/); + expect(YValueElement).toBeDefined(); + expect(YValueElement.textContent?.trim()).toBe(YValue.toString()); + }); + + test('displays the correct YValue when YValue is a date', () => { + const YValue = new Date('2021-01-01'); + const { container } = render(); + const YValueElement = getByText(container, '1/1/2021'); + expect(YValueElement).toBeDefined(); + expect(YValueElement.textContent?.trim()).toBe(YValue.toLocaleDateString()); + }); + + test('displays the correct Legend', () => { + const Legend = 'Sample Legend'; + const { container } = render(); + const LegendElement = getByText(container, /Sample Legend/); + expect(LegendElement).toBeDefined(); + }); + + test('displays the correct Legend when Legend is a number', () => { + const Legend = 123; + const { container } = render(); + const LegendElement = getByText(container, /123/); + expect(LegendElement).toBeDefined(); + expect(LegendElement.textContent?.trim()).toBe(Legend.toString()); + }); + + test('displays the correct Legend when Legend is a date', () => { + const Legend = new Date('2021-01-01'); + const { container } = render(); + const LegendElement = getByText(container, '1/1/2021'); + expect(LegendElement).toBeDefined(); + expect(LegendElement.textContent?.trim()).toBe(Legend.toLocaleDateString()); + }); + + test('displays the popover card correctly when Legend is undefined', () => { + const Legend = undefined; + const { container } = render(); + const LegendElement = getByClass(container, /calloutlegendText/)[0] as HTMLElement; + expect(LegendElement.children[0]).toBeUndefined(); + }); + + test('displays the correct ratio', () => { + const ratio: [number, number] = [1, 2]; + const { container } = render(); + expect(getByClass(container, /ratio/)).toBeDefined(); + const numerator = getByText(container, '1'); + expect(numerator).toBeDefined(); + expect(numerator.textContent).toBe(ratio[0].toLocaleString()); + const denominator = getByText(container, '2'); + expect(denominator).toBeDefined(); + expect(denominator.textContent).toBe(ratio[1].toLocaleString()); + }); + + it('displays the correct descriptionMessage', () => { + const descriptionMessage = 'Sample descriptionMessage'; + const { container } = render(); + const descriptionMessageElement = getByText(container, /Sample descriptionMessage/); + expect(descriptionMessageElement).toBeDefined(); + expect(descriptionMessageElement.textContent?.trim()).toBe(descriptionMessage); + }); +}); + +describe('Popover - axe-core', () => { + test('Should pass accessibility tests', async () => { + const { container } = render(); + let axeResults; + await act(async () => { + axeResults = await axe(container); + }); + expect(axeResults).toHaveNoViolations(); + }); +}); diff --git a/packages/charts/react-charts-preview/library/src/components/CommonComponents/ResponsiveContainer/ResponsiveContainer.tsx b/packages/charts/react-charts-preview/library/src/components/CommonComponents/ResponsiveContainer/ResponsiveContainer.tsx new file mode 100644 index 0000000000000..1f4e1eff5b811 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/CommonComponents/ResponsiveContainer/ResponsiveContainer.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; +import { ResponsiveContainerProps } from './ResponsiveContainer.types'; +import { useResponsiveContainerStyles_unstable } from './useResponsiveContainerStyles.styles'; + +export const ResponsiveContainer: React.FC = props => { + const containerRef = React.useRef(null); + const onResizeRef = React.useRef(); + const { targetDocument } = useFluent_unstable(); + const classes = useResponsiveContainerStyles_unstable(props); + + const [size, setSize] = React.useState<{ containerWidth?: number; containerHeight?: number }>({}); + + onResizeRef.current = props.onResize; + const _window = targetDocument?.defaultView; + + React.useEffect(() => { + let animationFrameId: number | undefined; + // eslint-disable-next-line no-restricted-globals + let resizeObserver: ResizeObserver | undefined; + + const resizeCallback = (entries: ResizeObserverEntry[]) => { + const { width: containerWidth, height: containerHeight } = entries[0].contentRect; + // rAF is an alternative to the throttle function. For more info, see: + // https://css-tricks.com/debouncing-throttling-explained-examples/#aa-requestanimationframe-raf + animationFrameId = _window?.requestAnimationFrame(() => { + setSize(prevSize => { + const roundedWidth = Math.floor(containerWidth); + const roundedHeight = Math.floor(containerHeight); + if (prevSize.containerWidth === roundedWidth && prevSize.containerHeight === roundedHeight) { + return prevSize; + } + + return { containerWidth: roundedWidth, containerHeight: roundedHeight }; + }); + }); + onResizeRef.current?.(containerWidth, containerHeight); + }; + + if (_window?.ResizeObserver) { + resizeObserver = new _window.ResizeObserver(resizeCallback); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + } + + return () => { + if (animationFrameId) { + _window?.cancelAnimationFrame(animationFrameId); + } + + resizeObserver?.disconnect(); + }; + }, [_window]); + + return ( +
+ {props.children(size)} +
+ ); +}; +ResponsiveContainer.displayName = 'ResponsiveContainer'; diff --git a/packages/charts/react-charts-preview/library/src/components/CommonComponents/ResponsiveContainer/ResponsiveContainer.types.ts b/packages/charts/react-charts-preview/library/src/components/CommonComponents/ResponsiveContainer/ResponsiveContainer.types.ts new file mode 100644 index 0000000000000..b751e2bcd1fc6 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/CommonComponents/ResponsiveContainer/ResponsiveContainer.types.ts @@ -0,0 +1,12 @@ +import * as React from 'react'; + +export interface ResponsiveContainerProps { + children: (props: { containerWidth?: number; containerHeight?: number }) => React.ReactNode; + onResize?: (width: number, height: number) => void; + width?: number | string; + height?: number | string; +} + +export interface ResponsiveContainerStyles { + root: string; +} diff --git a/packages/charts/react-charts-preview/library/src/components/CommonComponents/ResponsiveContainer/index.ts b/packages/charts/react-charts-preview/library/src/components/CommonComponents/ResponsiveContainer/index.ts new file mode 100644 index 0000000000000..210ba5eb8f124 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/CommonComponents/ResponsiveContainer/index.ts @@ -0,0 +1 @@ +export * from './ResponsiveContainer'; diff --git a/packages/charts/react-charts-preview/library/src/components/CommonComponents/ResponsiveContainer/useResponsiveContainerStyles.styles.ts b/packages/charts/react-charts-preview/library/src/components/CommonComponents/ResponsiveContainer/useResponsiveContainerStyles.styles.ts new file mode 100644 index 0000000000000..46b1afa902619 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/CommonComponents/ResponsiveContainer/useResponsiveContainerStyles.styles.ts @@ -0,0 +1,33 @@ +import { makeStyles, mergeClasses } from '@griffel/react'; +import { ResponsiveContainerProps, ResponsiveContainerStyles } from './ResponsiveContainer.types'; + +export const responsiveContainerClassNames: ResponsiveContainerStyles = { + root: 'fui-charts-resp__root', +}; + +const useStyles = makeStyles({ + root: { + width: '100%', + height: '100%', + + '& [class*="chartWrapper"]': { + width: '100%', // optional + // To prevent chart height from collapsing while resizing + height: '100%', // optional + }, + + '& svg': { + // This overrides the pixel width and height of svg allowing it to resize properly within flexbox + width: '100%', + height: '100%', + }, + }, +}); + +export const useResponsiveContainerStyles_unstable = (props: ResponsiveContainerProps): ResponsiveContainerStyles => { + const baseStyles = useStyles(); + + return { + root: mergeClasses(responsiveContainerClassNames.root, baseStyles.root), + }; +}; diff --git a/packages/charts/react-charts-preview/library/src/components/CommonComponents/index.ts b/packages/charts/react-charts-preview/library/src/components/CommonComponents/index.ts new file mode 100644 index 0000000000000..11bef8b939221 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/CommonComponents/index.ts @@ -0,0 +1,4 @@ +export * from './CartesianChart'; +export * from './CartesianChart.types'; +export * from './ChartPopover'; +export * from './ChartPopover.types'; diff --git a/packages/charts/react-charts-preview/library/src/components/CommonComponents/useCartesianChartStyles.styles.ts b/packages/charts/react-charts-preview/library/src/components/CommonComponents/useCartesianChartStyles.styles.ts new file mode 100644 index 0000000000000..53540f9945298 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/CommonComponents/useCartesianChartStyles.styles.ts @@ -0,0 +1,250 @@ +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { CartesianChartProps, CartesianChartStyles } from './CartesianChart.types'; +import { HighContrastSelectorBlack, HighContrastSelector } from '../../utilities/index'; +import { SlotClassNames } from '@fluentui/react-utilities/src/index'; +import { tokens, typographyStyles } from '@fluentui/react-theme'; +import { useRtl } from '../../utilities/utilities'; + +/** + * @internal + */ +export const cartesianchartClassNames: SlotClassNames = { + root: 'fui-cart__root', + chartWrapper: 'fui-cart__chartWrapper', + axisTitle: 'fui-cart__axisTitle', + xAxis: 'fui-cart__xAxis', + yAxis: 'fui-cart__yAxis', + opacityChangeOnHover: 'fui-cart__opacityChangeOnHover', + legendContainer: 'fui-cart__legendContainer', + calloutContentRoot: 'fui-cart__calloutContentRoot', + calloutDateTimeContainer: 'fui-cart__calloutDateTimeContainer', + calloutContentX: 'fui-cart__calloutContentX', + calloutBlockContainer: 'fui-cart__calloutBlockContainer', + calloutBlockContainertoDrawShapefalse: 'fui-cart__calloutBlockContainertoDrawShapefalse', + calloutBlockContainertoDrawShapetrue: 'fui-cart__calloutBlockContainertoDrawShapetrue', + shapeStyles: 'fui-cart__shapeStyles', + calloutlegendText: 'fui-cart__calloutlegendText', + calloutContentY: 'fui-cart__calloutContentY', + descriptionMessage: 'fui-cart__descriptionMessage', + hover: 'fui-cart__hover', + calloutInfoContainer: 'fui-cart__calloutInfoContainer', + tooltip: 'fui-cart__tooltip', + chartTitle: 'fui-cart__chartTitle', +}; + +/** + * Base Styles + */ +const useStyles = makeStyles({ + root: { + ...typographyStyles.body1, + display: 'flex', + width: '100%', + height: '100%', + flexDirection: 'column', + overflow: 'hidden', + }, + chartWrapper: { + overflow: 'auto', + }, + axisTitle: { + ...typographyStyles.caption2Strong, + fontStyle: 'normal', + textAlign: 'center', + color: tokens.colorNeutralForeground2, + fill: tokens.colorNeutralForeground1, + }, + xAxis: { + '& text': { + fill: tokens.colorNeutralForeground1, + ...typographyStyles.caption2Strong, + '& selectors': { + [HighContrastSelectorBlack]: { + fill: 'rgb(179, 179, 179)', + }, + }, + }, + '& line': { + opacity: 0.2, + stroke: tokens.colorNeutralForeground1, + width: '1px', + '& selectors': { + [HighContrastSelectorBlack]: { + opacity: 0.1, + stroke: 'rgb(179, 179, 179)', + }, + }, + }, + '& path': { + display: 'none', + }, + }, + yAxis: { + '& text': { + ...typographyStyles.caption2Strong, + fill: tokens.colorNeutralForeground1, + '& selectors': { + [HighContrastSelectorBlack]: { + fill: 'rgb(179, 179, 179)', + }, + }, + }, + '& line': { + opacity: 0.2, + stroke: tokens.colorNeutralForeground1, + '& selectors': { + [HighContrastSelectorBlack]: { + opacity: 0.1, + stroke: 'rgb(179, 179, 179)', + }, + }, + }, + '& path': { + display: 'none', + }, + }, + rtl: { + '& g': { + textAnchor: 'end', + }, + }, + ltr: {}, + opacityChangeOnHover: { + opacity: '0.1', //supports custom opacity ?? + cursor: 'default', //supports custom cursor ?? + }, + legendContainer: { + marginTop: tokens.spacingVerticalS, + marginLeft: tokens.spacingHorizontalXL, + }, + calloutContentRoot: { + display: 'grid', + overflow: 'hidden', + ...shorthands.padding('11px 16px 10px 16px'), + backgroundColor: tokens.colorNeutralBackground1, + backgroundBlendMode: 'normal, luminosity', + }, + calloutDateTimeContainer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }, + calloutContentX: { + ...typographyStyles.caption1, + opacity: '0.8', + color: tokens.colorNeutralForeground2, + }, + calloutBlockContainer: { + ...typographyStyles.body2, + marginTop: '13px', + color: tokens.colorNeutralForeground2, + }, + calloutBlockContainertoDrawShapefalse: { + '& selectors': { + [HighContrastSelector]: { + forcedColorAdjust: 'none', + }, + }, + ...shorthands.borderLeft('4px solid'), + paddingLeft: tokens.spacingHorizontalS, + }, + calloutBlockContainertoDrawShapetrue: { + display: 'flex', + }, + shapeStyles: { + marginRight: tokens.spacingHorizontalS, + }, + calloutLegendText: { + ...typographyStyles.caption1, + color: tokens.colorNeutralForeground2, + '& selectors': { + [HighContrastSelectorBlack]: { + color: 'rgb(255, 255, 255)', + }, + }, + }, + calloutContentY: { + ...typographyStyles.subtitle2Stronger, + '& selectors': { + [HighContrastSelectorBlack]: { + color: 'rgb(255, 255, 255)', + }, + }, + }, + descriptionMessage: { + ...typographyStyles.caption1, + color: tokens.colorNeutralForeground2, + marginTop: tokens.spacingVerticalMNudge, + paddingTop: tokens.spacingVerticalMNudge, + ...shorthands.borderTop(`1px solid ${tokens.colorNeutralStroke2}`), + }, +}); +/** + * Apply styling to the Carousel slots based on the state + */ +export const useCartesianChartStyles_unstable = (props: CartesianChartProps): CartesianChartStyles => { + const _useRtl = useRtl(); + const baseStyles = useStyles(); + return { + root: mergeClasses(cartesianchartClassNames.root, baseStyles.root /*props.styles?.root*/), + chartWrapper: mergeClasses( + cartesianchartClassNames.chartWrapper, + baseStyles.chartWrapper /*props.styles?.chartWrapper*/, + ), + axisTitle: mergeClasses(cartesianchartClassNames.axisTitle, baseStyles.axisTitle /*props.styles?.axisTitle*/), + xAxis: mergeClasses(cartesianchartClassNames.xAxis, baseStyles.xAxis /*props.styles?.xAxis*/), + yAxis: mergeClasses( + cartesianchartClassNames.yAxis, + baseStyles.yAxis, + _useRtl ? baseStyles.rtl : baseStyles.ltr /*props.styles?.yAxis*/, + ), + opacityChangeOnHover: mergeClasses( + cartesianchartClassNames.opacityChangeOnHover, + baseStyles.opacityChangeOnHover /*props.styles?.opacityChangeOnHover*/, + ), + legendContainer: mergeClasses( + cartesianchartClassNames.legendContainer, + baseStyles.legendContainer /*props.styles?.legendContainer*/, + ), + calloutContentRoot: mergeClasses( + cartesianchartClassNames.calloutContentRoot, + baseStyles.calloutContentRoot /*props.styles?. calloutContentRoot*/, + ), + calloutDateTimeContainer: mergeClasses( + cartesianchartClassNames.calloutDateTimeContainer, + baseStyles.calloutDateTimeContainer /*props.styles?.calloutDateTimeContainer*/, + ), + calloutContentX: mergeClasses( + cartesianchartClassNames.calloutContentX, + baseStyles.calloutContentX /*props.styles?.calloutContentX*/, + ), + calloutBlockContainer: mergeClasses( + cartesianchartClassNames.calloutBlockContainer, + baseStyles.calloutBlockContainer /*props.styles?.calloutBlockContainer*/, + ), + calloutBlockContainertoDrawShapefalse: mergeClasses( + cartesianchartClassNames.calloutBlockContainertoDrawShapefalse, + baseStyles.calloutBlockContainertoDrawShapefalse /*props.styles?.calloutBlockContainertoDrawShapefalse*/, + ), + calloutBlockContainertoDrawShapetrue: mergeClasses( + cartesianchartClassNames.calloutBlockContainertoDrawShapetrue, + baseStyles.calloutBlockContainertoDrawShapetrue /*props.styles?.calloutBlockContainertoDrawShapetrue*/, + ), + shapeStyles: mergeClasses( + cartesianchartClassNames.shapeStyles, + baseStyles.shapeStyles /*props.styles?.shapeStyles*/, + ), + calloutlegendText: mergeClasses( + cartesianchartClassNames.calloutlegendText, + baseStyles.calloutLegendText /*props.styles?.calloutlegendText*/, + ), + calloutContentY: mergeClasses( + cartesianchartClassNames.calloutContentY, + baseStyles.calloutContentY /*props.styles?.calloutContentY*/, + ), + descriptionMessage: mergeClasses( + cartesianchartClassNames.descriptionMessage, + baseStyles.descriptionMessage /*props.styles?. descriptionMessage*/, + ), + }; +}; diff --git a/packages/charts/react-charts-preview/library/src/components/CommonComponents/useChartPopoverStyles.styles.ts b/packages/charts/react-charts-preview/library/src/components/CommonComponents/useChartPopoverStyles.styles.ts new file mode 100644 index 0000000000000..e095570f9d8d9 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/CommonComponents/useChartPopoverStyles.styles.ts @@ -0,0 +1,171 @@ +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { HighContrastSelectorBlack, HighContrastSelector } from '../../utilities/index'; +import { SlotClassNames } from '@fluentui/react-utilities/src/index'; +import { tokens, typographyStyles } from '@fluentui/react-theme'; +import { ChartPopoverProps, PopoverComponentStyles } from './ChartPopover.types'; + +/** + * @internal + */ +export const popoverClassNames: SlotClassNames = { + calloutContentRoot: 'fui-cart__calloutContentRoot', + calloutDateTimeContainer: 'fui-cart__calloutDateTimeContainer', + calloutContentX: 'fui-cart__calloutContentX', + calloutBlockContainer: 'fui-cart__calloutBlockContainer', + calloutBlockContainertoDrawShapefalse: 'fui-cart__calloutBlockContainertoDrawShapefalse', + calloutBlockContainertoDrawShapetrue: 'fui-cart__calloutBlockContainertoDrawShapetrue', + shapeStyles: 'fui-cart__shapeStyles', + calloutlegendText: 'fui-cart__calloutlegendText', + calloutContentY: 'fui-cart__calloutContentY', + descriptionMessage: 'fui-cart__descriptionMessage', + ratio: 'fui-cart__ratio', + numerator: 'fui-cart__numerator', + denominator: 'fui-cart__denominator', + calloutInfoContainer: 'fui-cart__calloutInfoContainer', +}; + +/** + * Base Styles + */ +const useStyles = makeStyles({ + calloutContentRoot: { + display: 'contents', + overflow: 'hidden', + ...shorthands.padding('11px 16px 10px 16px'), + backgroundColor: tokens.colorNeutralBackground1, + backgroundBlendMode: 'normal, luminosity', + }, + calloutDateTimeContainer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }, + calloutContentX: { + ...typographyStyles.caption1, + opacity: '0.8', + color: tokens.colorNeutralForeground2, + }, + calloutBlockContainer: { + color: tokens.colorNeutralForeground2, + }, + calloutBlockContainerCartesian: { + ...typographyStyles.caption1, + marginTop: '13px', + }, + calloutBlockContainerNonCartesian: { + fontSize: tokens.fontSizeHero700, + lineHeight: '22px', + '& selectors': { + [HighContrastSelector]: { + forcedColorAdjust: 'none', + }, + }, + }, + calloutBlockContainertoDrawShapefalse: { + '& selectors': { + [HighContrastSelector]: { + forcedColorAdjust: 'none', + }, + }, + paddingLeft: tokens.spacingHorizontalS, + }, + calloutBlockContainertoDrawShapetrue: { display: 'inline-grid' }, + shapeStyles: { + marginRight: tokens.spacingHorizontalS, + }, + calloutLegendText: { + ...typographyStyles.caption1, + color: tokens.colorNeutralForeground2, + '& selectors': { + [HighContrastSelectorBlack]: { + color: 'rgb(255, 255, 255)', + }, + }, + }, + calloutContentY: { + '& selectors': { + [HighContrastSelectorBlack]: { + color: 'rgb(255, 255, 255)', + }, + }, + }, + calloutContentYCartesian: { + ...typographyStyles.subtitle2Stronger, + }, + calloutContentYNonCartesian: { + ...typographyStyles.title2, + }, + descriptionMessage: { + ...typographyStyles.caption1, + color: tokens.colorNeutralForeground2, + marginTop: tokens.spacingVerticalMNudge, + paddingTop: tokens.spacingVerticalMNudge, + ...shorthands.borderTop(`1px solid ${tokens.colorNeutralStroke2}`), + }, + ratio: { + ...typographyStyles.caption2, + marginLeft: tokens.spacingHorizontalSNudge, + color: tokens.colorNeutralForeground1, + }, + numerator: { + ...typographyStyles.caption2Strong, + }, + denominator: { + ...typographyStyles.caption2Strong, + }, + calloutInfoContainer: { + paddingLeft: tokens.spacingHorizontalS, + }, +}); +/** + * Apply styling to the Carousel slots based on the state + */ +export const usePopoverStyles_unstable = (props: ChartPopoverProps): PopoverComponentStyles => { + const { isCartesian } = props; + const baseStyles = useStyles(); + return { + calloutContentRoot: mergeClasses( + popoverClassNames.calloutContentRoot, + baseStyles.calloutContentRoot /*props.styles?. calloutContentRoot*/, + ), + calloutDateTimeContainer: mergeClasses( + popoverClassNames.calloutDateTimeContainer, + baseStyles.calloutDateTimeContainer /*props.styles?.calloutDateTimeContainer*/, + ), + calloutContentX: mergeClasses( + popoverClassNames.calloutContentX, + baseStyles.calloutContentX /*props.styles?.calloutContentX*/, + ), + calloutBlockContainer: mergeClasses( + popoverClassNames.calloutBlockContainer, + baseStyles.calloutBlockContainer /*props.styles?.calloutBlockContainerCartesian*/, + isCartesian ? baseStyles.calloutBlockContainerCartesian : baseStyles.calloutBlockContainerNonCartesian, + ), + calloutBlockContainertoDrawShapefalse: mergeClasses( + popoverClassNames.calloutBlockContainertoDrawShapefalse, + baseStyles.calloutBlockContainertoDrawShapefalse /*props.styles?.calloutBlockContainertoDrawShapefalse*/, + ), + calloutBlockContainertoDrawShapetrue: mergeClasses( + popoverClassNames.calloutBlockContainertoDrawShapetrue, + baseStyles.calloutBlockContainertoDrawShapetrue /*props.styles?.calloutBlockContainertoDrawShapetrue*/, + ), + shapeStyles: mergeClasses(popoverClassNames.shapeStyles, baseStyles.shapeStyles /*props.styles?.shapeStyles*/), + calloutlegendText: mergeClasses( + popoverClassNames.calloutlegendText, + baseStyles.calloutLegendText /*props.styles?.calloutlegendText*/, + ), + calloutContentY: mergeClasses( + popoverClassNames.calloutContentY, + baseStyles.calloutContentY /*props.styles?.calloutContentYNonCartesian*/, + isCartesian ? baseStyles.calloutContentYCartesian : baseStyles.calloutContentYNonCartesian, + ), + descriptionMessage: mergeClasses( + popoverClassNames.descriptionMessage, + baseStyles.descriptionMessage /*props.styles?. descriptionMessage*/, + ), + ratio: mergeClasses(popoverClassNames.ratio, baseStyles.ratio /*props.styles?.ratio*/), + numerator: mergeClasses(popoverClassNames.numerator, baseStyles.numerator /*props.styles?.numerator*/), + denominator: mergeClasses(popoverClassNames.denominator, baseStyles.denominator /*props.styles?.denominator*/), + calloutInfoContainer: mergeClasses(popoverClassNames.calloutInfoContainer, baseStyles.calloutInfoContainer), + }; +}; diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/Arc/Arc.tsx b/packages/charts/react-charts-preview/library/src/components/DonutChart/Arc/Arc.tsx new file mode 100644 index 0000000000000..e0eb1497a86f3 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/Arc/Arc.tsx @@ -0,0 +1,125 @@ +import * as React from 'react'; +import { arc as d3Arc } from 'd3-shape'; +import { useArcStyles_unstable } from './useArcStyles.styles'; +import { ChartDataPoint } from '../index'; +import { ArcProps } from './index'; +import { format as d3Format } from 'd3-format'; +import { formatValueWithSIPrefix, useRtl } from '../../../utilities/index'; + +// Create a Arc within Donut Chart variant which uses these default styles and this styled subcomponent. +/** + * Arc component within Donut Chart. + * {@docCategory ArcDonutChart} + */ +export const Arc: React.FunctionComponent = React.forwardRef( + (props, forwardedRef) => { + const arc = d3Arc(); + const currentRef = React.createRef(); + const _isRTL: boolean = useRtl(); + const classes = useArcStyles_unstable(props); + + React.useEffect(() => { + _updateChart(props); + }, [props]); + + function _onFocus(data: ChartDataPoint, id: string): void { + props.onFocusCallback!(data, id, currentRef.current); + } + + function _hoverOn(data: ChartDataPoint, mouseEvent: React.MouseEvent): void { + mouseEvent.persist(); + props.hoverOnCallback!(data, mouseEvent); + } + + function _hoverOff(): void { + props.hoverLeaveCallback!(); + } + + function _onBlur(): void { + props.onBlurCallback!(); + } + + function _getAriaLabel(): string { + const point = props.data!.data; + const legend = point.xAxisCalloutData || point.legend; + const yValue = point.yAxisCalloutData || point.data || 0; + return point.callOutAccessibilityData?.ariaLabel || (legend ? `${legend}, ` : '') + `${yValue}.`; + } + + function _renderArcLabel(className: string) { + const { data, innerRadius, outerRadius, showLabelsInPercent, totalValue, hideLabels, activeArc } = props; + + if ( + hideLabels || + Math.abs(data!.endAngle - data!.startAngle) < Math.PI / 12 || + (activeArc !== data!.data.legend && activeArc !== '') + ) { + return null; + } + + const [base, perp] = arc.centroid({ ...data!, innerRadius, outerRadius }); + const hyp = Math.sqrt(base * base + perp * perp); + const labelRadius = Math.max(innerRadius!, outerRadius!) + 2; + const angle = (data!.startAngle + data!.endAngle) / 2; + const arcValue = data!.value; + + return ( + Math.PI !== _isRTL ? 'end' : 'start'} + dominantBaseline={angle > Math.PI / 2 && angle < (3 * Math.PI) / 2 ? 'hanging' : 'auto'} + className={className} + aria-hidden={true} + > + {showLabelsInPercent + ? d3Format('.0%')(totalValue! === 0 ? 0 : arcValue / totalValue!) + : formatValueWithSIPrefix(arcValue)} + + ); + } + + function _updateChart(newProps: ArcProps): void { + if (newProps.arc && newProps.innerRadius && newProps.outerRadius) { + newProps.arc.innerRadius(newProps.innerRadius); + newProps.arc.outerRadius(newProps.outerRadius); + } + } + + const { href, focusedArcId } = props; + //TO DO 'replace' is throwing error + const id = props.uniqText! + props.data!.data.legend!.replace(/\s+/, '') + props.data!.data.data; + const opacity: number = props.activeArc === props.data!.data.legend || props.activeArc === '' ? 1 : 0.1; + return ( + + {!!focusedArcId && focusedArcId === id && ( + // TODO innerradius and outerradius were absent + + )} + + {_renderArcLabel(classes.arcLabel)} + + ); + }, +); +Arc.displayName = 'Arc'; diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/Arc/Arc.types.ts b/packages/charts/react-charts-preview/library/src/components/DonutChart/Arc/Arc.types.ts new file mode 100644 index 0000000000000..5c434fa213de2 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/Arc/Arc.types.ts @@ -0,0 +1,151 @@ +import { ChartDataPoint } from '../index'; +export interface ArcProps { + /** + * Data to render in the Arc. + */ + data?: ArcData; + + /** + * Data to render focused Arc + */ + focusData?: ArcData; + + /** + * id of the focused arc + */ + focusedArcId?: string; + /** + * shape for Arc. + */ + /* eslint-disable @typescript-eslint/no-explicit-any */ + arc?: any; + + /** + * innerRadius of the Arc. + */ + innerRadius: number; + + /** + * outerRadius of the Arc. + */ + outerRadius: number; + + /** + * Color for the Arc. + */ + color: string; + + /** + * Defines the function that is executed upon hovering over the legend + */ + hoverOnCallback?: Function; + + /** + * Defines the function that is executed upon hovering over the legend + */ + onFocusCallback?: Function; + + /** + * Defines the function that is executed upon hovering Leave the legend + */ + onBlurCallback?: Function; + + /** + * Defines the function that is executed upon hovering Leave the legend + */ + hoverLeaveCallback?: Function; + + /** + * Uniq string for chart + */ + uniqText?: string; + + /** + * string for callout id + */ + calloutId?: string; + + /** + * Active Arc for chart + */ + activeArc?: string; + + /** + * internal prop for href + */ + href?: string; + + /** + * props for inside donut value + */ + valueInsideDonut?: string | number; + + /** + * Prop to show the arc labels in percentage format + */ + showLabelsInPercent?: boolean; + + /** + * Prop used to define the sum of all arc values + */ + totalValue?: number; + + /** + * Prop to hide the arc labels + */ + hideLabels?: boolean; + + /** + * Call to provide customized styling that will layer on top of the variant rules. + */ + styles?: ArcStyles; + + /** + * Additional CSS class(es) to apply to the Chart. + */ + className?: string; +} + +export interface ArcData { + /** + * Data to render in the chart for individual arc. + */ + data: ChartDataPoint; + /** + * endAngle of the Arc + */ + endAngle: number; + /** + * index of the Arc + */ + index: number; + /** + * padAngle of the Arc + */ + padAngle: number; + /** + * startAngle of the Arc + */ + startAngle: number; + /** + * value of the Arc + */ + value: number; +} + +export interface ArcStyles { + /** + * Style set for the card header component root + */ + root: string; + + /** + * styles for the focus + */ + focusRing: string; + + /** + * Style for the arc labels + */ + arcLabel: string; +} diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/Arc/index.ts b/packages/charts/react-charts-preview/library/src/components/DonutChart/Arc/index.ts new file mode 100644 index 0000000000000..4b01e2352b53d --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/Arc/index.ts @@ -0,0 +1,2 @@ +export * from './Arc'; +export * from './Arc.types'; diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/Arc/useArcStyles.styles.ts b/packages/charts/react-charts-preview/library/src/components/DonutChart/Arc/useArcStyles.styles.ts new file mode 100644 index 0000000000000..e887dfc051c8c --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/Arc/useArcStyles.styles.ts @@ -0,0 +1,52 @@ +import { ArcProps, ArcStyles } from './Arc.types'; +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { tokens, typographyStyles } from '@fluentui/react-theme'; +import type { SlotClassNames } from '@fluentui/react-utilities'; + +/** + * @internal + */ +export const donutArcClassNames: SlotClassNames = { + root: 'fui-donut-arc__root', + focusRing: 'fui-donut-arc__focusRing', + arcLabel: 'fui-donut-arc__arcLabel', +}; + +/** + * Base Styles + */ +const useStyles = makeStyles({ + root: { + cursor: 'default', + ...shorthands.outline('transparent'), + stroke: tokens.colorNeutralBackground1, + '& selectors': { + '::-moz-focus-inner': { + ...shorthands.border('0'), + }, + }, + }, + focusRing: { + stroke: tokens.colorStrokeFocus2, + strokeWidth: tokens.strokeWidthThickest, + fill: 'transparent', + }, + arcLabel: { + ...typographyStyles.caption1Strong, + fill: tokens.colorNeutralForeground1, + }, +}); + +/** + * Apply styling to the Arc components + */ +export const useArcStyles_unstable = (props: ArcProps): ArcStyles => { + const { className } = props; + const baseStyles = useStyles(); + + return { + root: mergeClasses(donutArcClassNames.root, baseStyles.root, className, props.styles?.root), + focusRing: mergeClasses(donutArcClassNames.focusRing, baseStyles.focusRing, props.styles?.focusRing), + arcLabel: mergeClasses(donutArcClassNames.arcLabel, baseStyles.arcLabel, props.styles?.arcLabel), + }; +}; diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/DonutChart.test.tsx b/packages/charts/react-charts-preview/library/src/components/DonutChart/DonutChart.test.tsx new file mode 100644 index 0000000000000..275753a89d26a --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/DonutChart.test.tsx @@ -0,0 +1,255 @@ +jest.mock('react-dom'); +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as React from 'react'; +import { chartPointsDC, chartPointsDCElevateMinimums, pointsDC } from '../../utilities/test-data'; +import * as renderer from 'react-test-renderer'; +import { mount, ReactWrapper } from 'enzyme'; +import { DonutChartProps, DonutChart } from './index'; +import { ChartProps, ChartDataPoint } from '../../index'; +import toJson from 'enzyme-to-json'; +const rendererAct = renderer.act; +import { act as domAct } from 'react-dom/test-utils'; + +// Wrapper of the DonutChart to be tested. +let wrapper: ReactWrapper | undefined; + +function sharedAfterEach() { + if (wrapper) { + wrapper.unmount(); + wrapper = undefined; + } + + // Do this after unmounting the wrapper to make sure if any timers cleaned up on unmount are + // cleaned up in fake timers world + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((global.setTimeout as any).mock) { + jest.useRealTimers(); + } +} + +const pointsNoColors: ChartDataPoint[] = [ + { legend: 'first', data: 20000, xAxisCalloutData: '2020/04/30' }, + { legend: 'second', data: 39000, xAxisCalloutData: '2020/04/20' }, + { legend: 'third', data: 45000, xAxisCalloutData: '2020/04/25' }, +]; + +const chartTitle = 'Donut chart example'; + +export const emptyChartPoints: ChartProps = { + chartTitle, + chartData: [], +}; + +export const noColorsChartPoints: ChartProps = { + chartTitle, + chartData: pointsNoColors, +}; + +describe('DonutChart snapShot testing', () => { + it('renders DonutChart correctly', () => { + let component: any; + rendererAct(() => { + component = renderer.create(); + }); + const tree = component!.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders DonutChart correctly without color points', () => { + const chartPointColor = pointsDC[0].color; + delete pointsDC[0].color; + + let component: any; + rendererAct(() => { + component = renderer.create(); + }); + const tree = component!.toJSON(); + expect(tree).toMatchSnapshot(); + pointsDC[0].color = chartPointColor; + }); + + it('renders hideLegend correctly', () => { + let component: any; + rendererAct(() => { + component = renderer.create(); + }); + const tree = component!.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders hideTooltip correctly', () => { + let component: any; + rendererAct(() => { + component = renderer.create(); + }); + const tree = component!.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders enabledLegendsWrapLines correctly', () => { + let component: any; + rendererAct(() => { + component = renderer.create(); + }); + const tree = component!.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders value inside onf the pie', () => { + let component: any; + rendererAct(() => { + component = renderer.create(); + }); + const tree = component!.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('Should render arc labels', () => { + let component: any; + rendererAct(() => { + component = renderer.create(); + }); + const tree = component!.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('Should render arc labels in percentage format', () => { + let component: any; + rendererAct(() => { + component = renderer.create(); + }); + const tree = component!.toJSON(); + expect(tree).toMatchSnapshot(); + }); + it('Should elevate all smaller values to minimums', () => { + let component: any; + rendererAct(() => { + component = renderer.create(); + }); + const tree = component!.toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); + +describe('DonutChart - basic props', () => { + afterEach(sharedAfterEach); + + it('Should mount legend when hideLegend false ', () => { + domAct(() => { + wrapper = mount(); + }); + const hideLegendDOM = wrapper!.getDOMNode().querySelectorAll('[class^="legendContainer"]'); + expect(hideLegendDOM).toBeDefined(); + }); + + it('Should mount callout when hideTootip false ', () => { + domAct(() => { + wrapper = mount(); + }); + const hideLegendDOM = wrapper!.getDOMNode().querySelectorAll('[class^="ms-Layer"]'); + expect(hideLegendDOM).toBeDefined(); + }); + + it('Should not render onRenderCalloutPerStack ', () => { + domAct(() => { + wrapper = mount(); + }); + const renderedDOM = wrapper!.getDOMNode().getElementsByClassName('.onRenderCalloutPerStack'); + expect(renderedDOM!.length).toBe(0); + }); + + it('Should render onRenderCalloutPerDataPoint ', () => { + domAct(() => { + wrapper = mount( + + props ? ( +
+

Custom Callout Content

+
+ ) : null + } */ + />, + ); + }); + const renderedDOM = wrapper!.getDOMNode().getElementsByClassName('.onRenderCalloutPerDataPoint'); + expect(renderedDOM).toBeDefined(); + }); + + it('Should not render onRenderCalloutPerDataPoint ', () => { + domAct(() => { + wrapper = mount(); + }); + const renderedDOM = wrapper!.getDOMNode().getElementsByClassName('.onRenderCalloutPerDataPoint'); + expect(renderedDOM!.length).toBe(0); + }); +}); + +describe('DonutChart - mouse events', () => { + afterEach(sharedAfterEach); + + it('Should render callout correctly on mouseover', () => { + domAct(() => { + wrapper = mount(); + }); + wrapper!.find('path[id^="_Pie_"]').at(0).simulate('mouseover'); + const tree = toJson(wrapper!, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + }); + + it('Should render callout correctly on mousemove', () => { + domAct(() => { + wrapper = mount(); + }); + wrapper!.find('path[id^="_Pie_"]').at(0).simulate('mousemove'); + const html1 = wrapper!.html(); + wrapper!.find('path[id^="_Pie_"]').at(0).simulate('mouseleave'); + wrapper!.find('path[id^="_Pie_"]').at(1).simulate('mousemove'); + const html2 = wrapper!.html(); + expect(html1).not.toBe(html2); + }); + + it('Should render customized callout on mouseover', () => { + domAct(() => { + wrapper = mount( + + props ? ( +
+
{JSON.stringify(props, null, 2)}
+
+ ) : null + } */ + />, + ); + }); + wrapper!.find('path[id^="_Pie_"]').at(0).simulate('mouseover'); + const tree = toJson(wrapper!, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + }); +}); + +describe('Render empty chart aria label div when chart is empty', () => { + it('No empty chart aria label div rendered', () => { + domAct(() => { + wrapper = mount(); + }); + const renderedDOM = wrapper!.findWhere( + (node: ReactWrapper) => node.prop('aria-label') === 'Graph has no data to display', + ); + expect(renderedDOM!.length).toBe(0); + }); + + it('Empty chart aria label div rendered', () => { + domAct(() => { + wrapper = mount(); + }); + const renderedDOM = wrapper!.findWhere( + (node: ReactWrapper) => node.prop('aria-label') === 'Graph has no data to display', + ); + expect(renderedDOM!.length).toBe(1); + }); +}); diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/DonutChart.tsx b/packages/charts/react-charts-preview/library/src/components/DonutChart/DonutChart.tsx new file mode 100644 index 0000000000000..0a0b100fa588d --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/DonutChart.tsx @@ -0,0 +1,342 @@ +/* eslint-disable react/jsx-no-bind */ +import * as React from 'react'; +import { Pie } from './Pie/index'; +import { DonutChartProps } from './DonutChart.types'; +import { useDonutChartStyles_unstable } from './useDonutChartStyles.styles'; +import { ChartDataPoint } from '../../DonutChart'; +import { convertToLocaleString } from '../../utilities/locale-util'; +import { getColorFromToken, getNextColor } from '../../utilities/index'; +import { Legend, Legends } from '../../index'; +import { useId } from '@fluentui/react-utilities'; +import { useFocusableGroup } from '@fluentui/react-tabster'; +import { ChartPopover } from '../CommonComponents/ChartPopover'; +import { ResponsiveContainer } from '../CommonComponents/ResponsiveContainer'; + +const MIN_LEGEND_CONTAINER_HEIGHT = 40; + +// Create a DonutChart variant which uses these default styles and this styled subcomponent. +/** + * Donutchart component. + * {@docCategory DonutChart} + */ +const DonutChartBase: React.FunctionComponent = React.forwardRef( + (props, forwardedRef) => { + const _rootElem = React.useRef(null); + const _uniqText: string = useId('_Pie_'); + /* eslint-disable @typescript-eslint/no-explicit-any */ + let _calloutAnchorPoint: ChartDataPoint | null; + let _emptyChartId: string | null; + const legendContainer = React.useRef(null); + const prevSize = React.useRef<{ width?: number; height?: number }>({}); + + const [value, setValue] = React.useState(''); + const [legend, setLegend] = React.useState(''); + const [_width, setWidth] = React.useState(props.width || 200); + const [_height, setHeight] = React.useState(props.height || 200); + const [activeLegend, setActiveLegend] = React.useState(''); + const [color, setColor] = React.useState(''); + const [xCalloutValue, setXCalloutValue] = React.useState(''); + const [yCalloutValue, setYCalloutValue] = React.useState(''); + const [selectedLegend, setSelectedLegend] = React.useState(''); + const [focusedArcId, setFocusedArcId] = React.useState(''); + const [dataPointCalloutProps, setDataPointCalloutProps] = React.useState(); + const [clickPosition, setClickPosition] = React.useState({ x: 0, y: 0 }); + const [isPopoverOpen, setPopoverOpen] = React.useState(false); + + React.useEffect(() => { + _fitParentContainer(); + }, []); + + React.useEffect(() => { + if (prevSize.current.height !== props.height || prevSize.current.width !== props.width) { + _fitParentContainer(); + } + prevSize.current.height = props.height; + prevSize.current.width = props.width; + }, [props.width, props.height]); + + function _elevateToMinimums(data: ChartDataPoint[]) { + let sumOfData = 0; + const minPercent = 0.01; + const elevatedData: ChartDataPoint[] = []; + data.forEach(item => { + sumOfData += item.data!; + }); + data.forEach(item => { + elevatedData.push( + minPercent * sumOfData > item.data! && item.data! > 0 + ? { + ...item, + data: minPercent * sumOfData, + yAxisCalloutData: + item.yAxisCalloutData === undefined ? item.data!.toLocaleString() : item.yAxisCalloutData, + } + : item, + ); + }); + return elevatedData; + } + function _createLegends(chartData: ChartDataPoint[]): JSX.Element { + const legendDataItems = chartData.map((point: ChartDataPoint, index: number) => { + const color: string = point.color!; + // mapping data to the format Legends component needs + const legend: Legend = { + title: point.legend!, + color, + action: () => { + if (selectedLegend === point.legend) { + setSelectedLegend(''); + } else { + setSelectedLegend(point.legend!); + } + }, + hoverAction: () => { + _handleChartMouseLeave(); + setActiveLegend(point.legend!); + }, + onMouseOutAction: () => { + setActiveLegend(''); + }, + }; + return legend; + }); + const legends = ( + + ); + return legends; + } + + function _focusCallback(data: ChartDataPoint, id: string, element: SVGPathElement): void { + setPopoverOpen(selectedLegend === '' || selectedLegend === data.legend); + setValue(data.data!.toString()); + setLegend(data.legend); + setColor(data.color!); + setXCalloutValue(data.xAxisCalloutData!); + setYCalloutValue(data.yAxisCalloutData!); + setFocusedArcId(id); + setDataPointCalloutProps(data); + } + + function _hoverCallback(data: ChartDataPoint, e: React.MouseEvent): void { + if (_calloutAnchorPoint !== data) { + _calloutAnchorPoint = data; + setPopoverOpen(selectedLegend === '' || selectedLegend === data.legend); + setValue(data.data!.toString()); + setLegend(data.legend); + setColor(data.color!); + setXCalloutValue(data.xAxisCalloutData!); + setYCalloutValue(data.yAxisCalloutData!); + setDataPointCalloutProps(data); + updatePosition(e.clientX, e.clientY); + } + } + function _onBlur(): void { + setFocusedArcId(''); + } + + function _hoverLeave(): void { + /**/ + } + + function _handleChartMouseLeave() { + _calloutAnchorPoint = null; + setPopoverOpen(false); + } + + function _valueInsideDonut(valueInsideDonut: string | number | undefined, data: ChartDataPoint[]) { + const highlightedLegend = _getHighlightedLegend(); + if (valueInsideDonut !== undefined && (highlightedLegend !== '' || isPopoverOpen)) { + let legendValue = valueInsideDonut; + data!.map((point: ChartDataPoint, index: number) => { + if (point.legend === highlightedLegend || (isPopoverOpen && point.legend === legend)) { + legendValue = point.yAxisCalloutData ? point.yAxisCalloutData : point.data!; + } + return; + }); + return legendValue; + } else { + return valueInsideDonut; + } + } + + function _toLocaleString(data: string | number | undefined) { + const localeString = convertToLocaleString(data, props.culture); + if (!localeString) { + return data; + } + return localeString?.toString(); + } + + /** + * This function returns + * the selected legend if there is one + * or the hovered legend if none of the legends is selected. + * Note: This won't work in case of multiple legends selection. + */ + function _getHighlightedLegend() { + return selectedLegend || activeLegend; + } + + function _isChartEmpty(): boolean { + return !( + props.data && + props.data.chartData && + props.data.chartData!.filter((d: ChartDataPoint) => d.data! > 0).length > 0 + ); + } + + function _addDefaultColors(donutChartDataPoint?: ChartDataPoint[]): ChartDataPoint[] { + return donutChartDataPoint + ? donutChartDataPoint.map((item, index) => { + let defaultColor: string; + if (typeof item.color === 'undefined') { + defaultColor = getNextColor(index, 0); + } else { + defaultColor = getColorFromToken(item.color); + } + return { ...item, defaultColor }; + }) + : []; + } + + function updatePosition(newX: number, newY: number) { + const threshold = 1; // Set a threshold for movement + const { x, y } = clickPosition; + // Calculate the distance moved + const distance = Math.sqrt(Math.pow(newX - x, 2) + Math.pow(newY - y, 2)); + // Update the position only if the distance moved is greater than the threshold + if (distance > threshold) { + setClickPosition({ x: newX, y: newY }); + setPopoverOpen(true); + } + } + + /** + * When screen resizes, along with screen, chart also auto adjusted. + * This method used to adjust height and width of the charts. + */ + function _fitParentContainer(): void { + //_reqID = requestAnimationFrame(() => { + let legendContainerHeight; + if (props.hideLegend) { + // If there is no legend, need not to allocate some space from total chart space. + legendContainerHeight = 0; + } else { + const legendContainerComputedStyles = legendContainer.current && getComputedStyle(legendContainer.current); + legendContainerHeight = + ((legendContainer.current && legendContainer.current.getBoundingClientRect().height) || + MIN_LEGEND_CONTAINER_HEIGHT) + + parseFloat((legendContainerComputedStyles && legendContainerComputedStyles.marginTop) || '0') + + parseFloat((legendContainerComputedStyles && legendContainerComputedStyles.marginBottom) || '0'); + } + if (props.parentRef || _rootElem.current) { + const container = props.parentRef ? props.parentRef : _rootElem.current!; + const currentContainerWidth = container.getBoundingClientRect().width; + const currentContainerHeight = + container.getBoundingClientRect().height > legendContainerHeight + ? container.getBoundingClientRect().height + : 200; + const shouldResize = + _width !== currentContainerWidth || _height !== currentContainerHeight - legendContainerHeight; + if (shouldResize) { + setWidth(currentContainerWidth); + setHeight(currentContainerHeight - legendContainerHeight); + } + } + //}); + } + + const { data, hideLegend = false } = props; + const points = _addDefaultColors(data?.chartData); + + const classes = useDonutChartStyles_unstable(props); + + const legendBars = _createLegends(points); + const donutMarginHorizontal = props.hideLabels ? 0 : 80; + const donutMarginVertical = props.hideLabels ? 0 : 40; + const outerRadius = Math.min(_width! - donutMarginHorizontal, _height! - donutMarginVertical) / 2; + const chartData = _elevateToMinimums(points.filter((d: ChartDataPoint) => d.data! >= 0)); + const valueInsideDonut = _valueInsideDonut(props.valueInsideDonut!, chartData!); + const focusAttributes = useFocusableGroup(); + return !_isChartEmpty() ? ( +
(_rootElem.current = rootElem)} + onMouseLeave={_handleChartMouseLeave} + > +
+ + + +
+ + {!hideLegend && ( +
(legendContainer.current = e)} className={classes.legendContainer}> + {legendBars} +
+ )} +
+ ) : ( +
+ ); + }, +); + +export const DonutChart: React.FunctionComponent = props => { + if (!props.responsive) { + return ; + } + + return ( + + {({ containerWidth, containerHeight }) => ( + + )} + + ); +}; +DonutChart.displayName = 'DonutChart'; +DonutChart.defaultProps = { + innerRadius: 0, + hideLabels: true, + responsive: true, +}; diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/DonutChart.types.ts b/packages/charts/react-charts-preview/library/src/components/DonutChart/DonutChart.types.ts new file mode 100644 index 0000000000000..d26229063b28a --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/DonutChart.types.ts @@ -0,0 +1,92 @@ +import { CartesianChartProps, CartesianChartStyleProps } from '../CommonComponents/index'; +import { ChartProps, ChartDataPoint } from './index'; +import { ChartPopoverProps } from '../CommonComponents/ChartPopover.types'; + +/** + * Donut Chart properties. + * {@docCategory DonutChart} + */ +export interface DonutChartProps extends CartesianChartProps { + /** + * Data to render in the chart. + */ + data?: ChartProps; + + /** + * inner radius for donut size + */ + innerRadius?: number; + + /** + * Call to provide customized styling that will layer on top of the variant rules. + */ + styles?: DonutChartStyles; + + /** + * props for inside donut value + */ + valueInsideDonut?: string | number; + + /** + * Define a custom callout renderer for a data point + */ + onRenderCalloutPerDataPoint?: (dataPointCalloutProps: ChartDataPoint) => JSX.Element | undefined; + + /** + * Define a custom callout props override + */ + customProps?: (dataPointCalloutProps: ChartDataPoint) => ChartPopoverProps; + + /** + * props for the callout in the chart + */ + calloutProps?: ChartPopoverProps; + + /** + * The prop used to define the culture to localized the numbers + */ + culture?: string; + + /** + * Prop to show the arc labels in percentage format + * @default false + */ + showLabelsInPercent?: boolean; + + /** + * Prop to hide the arc labels + * @default true + */ + hideLabels?: boolean; +} + +/** + * Donut Chart style properties + * {@docCategory DonutChart} + */ +export interface DonutChartStyleProps extends CartesianChartStyleProps {} + +/** + * Donut Chart styles + * {@docCategory DonutChart} + */ +export interface DonutChartStyles { + /** + * Style for the root element. + */ + root?: string; + + /** + * Style for the chart. + */ + chart?: string; + /** + * Style for the legend container. + */ + legendContainer: string; + + /** + * Styles for the chart wrapper div + */ + chartWrapper?: string; +} diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/DonutChartRTL.test.tsx b/packages/charts/react-charts-preview/library/src/components/DonutChart/DonutChartRTL.test.tsx new file mode 100644 index 0000000000000..05df7d405f643 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/DonutChartRTL.test.tsx @@ -0,0 +1,206 @@ +import { render, screen, queryAllByAttribute, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { chartPointsDC } from '../../utilities/test-data'; +import { DonutChart } from './index'; +import * as React from 'react'; +import { FluentProvider } from '@fluentui/react-provider'; +import * as utils from '../../utilities/utilities'; +import { axe, toHaveNoViolations } from 'jest-axe'; + +expect.extend(toHaveNoViolations); + +beforeAll(() => { + // https://github.com/jsdom/jsdom/issues/3368 + global.ResizeObserver = class ResizeObserver { + public observe() { + // do nothing + } + public unobserve() { + // do nothing + } + public disconnect() { + // do nothing + } + }; +}); + +describe('Donut chart interactions', () => { + beforeEach(() => { + jest.spyOn(global.Math, 'random').mockReturnValue(0.1); + }); + afterEach(() => { + jest.spyOn(global.Math, 'random').mockRestore(); + }); + test('Should hide callout on mouse leave', () => { + // Arrange + const { container } = render(); + const getByClass = queryAllByAttribute.bind(null, 'class'); + // Act + const getById = queryAllByAttribute.bind(null, 'id'); + fireEvent.mouseOver(getById(container, /Pie/i)[0]); + expect(getByClass(container, /PopoverSurface/i)[0]).toBeDefined(); + fireEvent.mouseLeave(getById(container, /Pie/i)[0]); + // Assert + expect(getByClass(container, /PopoverSurface/i)[0]).not.toBeDefined(); + expect(container).toMatchSnapshot(); + }); + + test('Should show callout on focus', () => { + // Arrange + const { container } = render(); + + // Act + const getById = queryAllByAttribute.bind(null, 'id'); + fireEvent.focus(getById(container, /Pie/i)[0]); + + // Assert + expect(getById(container, /focusRing/i)).toBeDefined(); + }); + + test('Should remove focus on blur', () => { + // Arrange + const { container } = render(); + + // Act + const getById = queryAllByAttribute.bind(null, 'id'); + fireEvent.blur(getById(container, /Pie/i)[0]); + + // Assert + const value = getById(container, /Pie/i)[0].getAttribute('id'); + expect(value).not.toContain('focusRing'); + }); + + test('Should highlight the corresponding Pie on mouse over on legends', () => { + // Arrange + const { container } = render(); + + // Act + const legend = screen.queryByText('first'); + expect(legend).toBeDefined(); + fireEvent.mouseOver(legend!); + + // Assert + const getById = queryAllByAttribute.bind(null, 'id'); + expect(getById(container, /Pie.*?second/i)[0]).toHaveAttribute('opacity', '0.1'); + expect(getById(container, /Pie.*?third/i)[0]).toHaveAttribute('opacity', '0.1'); + }); + + test('Should select legend on single mouse click on legends', () => { + // Arrange + const { container } = render(); + + // Act + const legend = screen.queryByText('first'); + expect(legend).toBeDefined(); + fireEvent.click(legend!); + + // Assert + const getById = queryAllByAttribute.bind(null, 'id'); + expect(getById(container, /Pie.*?second/i)[0]).toHaveAttribute('opacity', '0.1'); + const firstLegend = screen.queryByText('first')?.closest('button'); + expect(firstLegend).toHaveAttribute('aria-selected', 'true'); + expect(firstLegend).toHaveAttribute( + 'style', + '--rect-height: 12px; --rect-backgroundColor: #E5E5E5; --rect-borderColor: #E5E5E5;', + ); + }); + + test('Should deselect legend on double mouse click on legends', () => { + // Arrange + const { container } = render(); + + // Act + const legend = screen.queryByText('first'); + expect(legend).toBeDefined(); + + //single click on first legend + fireEvent.click(legend!); + const getById = queryAllByAttribute.bind(null, 'id'); + expect(getById(container, /Pie.*?second/i)[0]).toHaveAttribute('opacity', '0.1'); + const firstLegend = screen.queryByText('first')?.closest('button'); + expect(firstLegend).toHaveAttribute('aria-selected', 'true'); + expect(firstLegend).toHaveAttribute( + 'style', + '--rect-height: 12px; --rect-backgroundColor: #E5E5E5; --rect-borderColor: #E5E5E5;', + ); + // double click on same first legend + fireEvent.click(legend!); + + // Assert + expect(firstLegend).toHaveAttribute('aria-selected', 'false'); + }); + + test('Should show Pies with same opacity on mouse out of legends', () => { + // Arrange + const { container } = render(); + + // Act + const legend = screen.queryByText('first'); + expect(legend).toBeDefined(); + fireEvent.mouseOver(legend!); + const getById = queryAllByAttribute.bind(null, 'id'); + expect(getById(container, /Pie.*?second/i)[0]).toHaveAttribute('opacity', '0.1'); + fireEvent.mouseOut(legend!); + + // Assert + expect(getById(container, /Pie.*?first/i)[0]).toHaveAttribute('opacity', '1'); + expect(getById(container, /Pie.*?second/i)[0]).toHaveAttribute('opacity', '1'); + }); + + test('Should display correct callout data on mouse move', async () => { + // Arrange + const { container } = render(); + const getByClass = queryAllByAttribute.bind(null, 'class'); + // Act + const getById = queryAllByAttribute.bind(null, 'id'); + fireEvent.mouseOver(getById(container, /Pie/i)[0]); + expect(getByClass(container, /PopoverSurface/i)[0]).toHaveTextContent('20,000'); + fireEvent.mouseLeave(getById(container, /Pie/i)[0]); + fireEvent.mouseOver(getById(container, /Pie/i)[1]); + + // Assert + await (() => { + expect(getByClass(container, /PopoverSurface/i)[1]).toHaveTextContent('39,000'); + }); + }); + + test('Should change value inside donut with the legend value on mouseOver legend ', () => { + // Mock the implementation of wrapTextInsideDonut as it internally calls a Browser Function like + // getComputedTextLength() which will otherwise lead to a crash if mounted + jest.spyOn(utils, 'wrapTextInsideDonut').mockImplementation(() => '1000'); + // Arrange + const { container } = render( + , + ); + const getByClass = queryAllByAttribute.bind(null, 'class'); + + // Act + fireEvent.mouseOver(screen.getByText('first')); + + // Assert + expect(getByClass(container, /insideDonutString.*?/)[0].textContent).toBe('20,000'); + }); + + test('Should reflect theme change', () => { + // Arrange + const { container } = render( + + + , + ); + + // Assert + expect(container).toMatchSnapshot(); + }); +}); + +describe('Donut Chart - axe-core', () => { + test('Should pass accessibility tests', async () => { + const { container } = render(); + let axeResults; + await act(async () => { + axeResults = await axe(container); + }); + expect(axeResults).toHaveNoViolations(); + }); +}); diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/Pie/Pie.tsx b/packages/charts/react-charts-preview/library/src/components/DonutChart/Pie/Pie.tsx new file mode 100644 index 0000000000000..e0a04a99bd0fd --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/Pie/Pie.tsx @@ -0,0 +1,100 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +/* eslint-disable react/jsx-no-bind */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as React from 'react'; +import { pie as d3Pie } from 'd3-shape'; +import { PieProps } from './index'; +import { Arc } from '../Arc/index'; +import { ChartDataPoint } from '../index'; +import { usePieStyles_unstable } from './usePieStyles.styles'; +import { wrapTextInsideDonut } from '../../../utilities/index'; +const TEXT_PADDING: number = 5; + +// Create a Pie within Donut Chart variant which uses these default styles and this styled subcomponent. +/** + * Pie component within Donut Chart. + * {@docCategory PieDonutChart} + */ +export const Pie: React.FunctionComponent = React.forwardRef( + (props, forwardedRef) => { + React.useEffect(() => { + wrapTextInsideDonut(classes.insideDonutString, props.innerRadius! * 2 - TEXT_PADDING); + }, []); + + let _totalValue: number; + const classes = usePieStyles_unstable(props); + const pieForFocusRing = d3Pie() + .sort(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .value((d: any) => d.data) + .padAngle(0); + + function _focusCallback(data: ChartDataPoint, id: string, e: SVGPathElement): void { + props.onFocusCallback!(data, id, e); + } + + function _hoverCallback(data: ChartDataPoint, e: React.MouseEvent): void { + props.hoverOnCallback!(data, e); + } + + function _computeTotalValue() { + let totalValue = 0; + props.data.forEach((arc: ChartDataPoint) => { + totalValue += arc.data!; + }); + return totalValue; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function arcGenerator(d: any, i: number, focusData: any, href?: string): JSX.Element { + const color = d && d.data && d.data.color; + return ( + + ); + } + + const { data } = props; + const focusData = pieForFocusRing(data.map(d => d.data!)); + + const piechart = d3Pie() + .sort(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .value((d: any) => d.data) + .padAngle(0.02)(data); + const translate = `translate(${props.width / 2}, ${props.height / 2})`; + + _totalValue = _computeTotalValue(); + + return ( + + {piechart.map((d: any, i: number) => arcGenerator(d, i, focusData[i], props.href))} + {props.valueInsideDonut && ( + + {props.valueInsideDonut} + + )} + + ); + }, +); +Pie.displayName = 'Pie'; diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/Pie/Pie.types.ts b/packages/charts/react-charts-preview/library/src/components/DonutChart/Pie/Pie.types.ts new file mode 100644 index 0000000000000..3160af372fa20 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/Pie/Pie.types.ts @@ -0,0 +1,108 @@ +import { ChartDataPoint } from '../index'; + +export interface PieProps { + /** + * Width of the Pie. + */ + width: number; + /** + * Height of the Pie. + */ + height: number; + /** + * outerRadius of the Pie. + */ + outerRadius: number; + /** + * innerRadius of the Pie. + */ + innerRadius: number; + /** + * Data to render in the Pie. + */ + data: ChartDataPoint[]; + /** + * shape for pie. + */ + /* eslint-disable @typescript-eslint/no-explicit-any */ + pie?: any; + + pieForFocusRing?: any; + + /** + * Defines the function that is executed upon hovering over the legend + */ + hoverOnCallback?: Function; + /** + * Defines the function that is executed upon hovering over the legend + */ + onFocusCallback?: Function; + /** + * Defines the function that is executed upon hovering Leave the legend + */ + onBlurCallback?: Function; + /** + * Defines the function that is executed upon hovering Leave the legend + */ + hoverLeaveCallback?: Function; + /** + * Uniq string for chart + */ + uniqText?: string; + /** + * Active Arc for chart + */ + activeArc?: string; + + /** + * string for callout id + */ + calloutId?: string; + + /** + * internal prop for href + */ + href?: string; + + /** + * props for inside donut value + */ + valueInsideDonut?: string | number; + + /** + * id of the focused arc + */ + focusedArcId?: string; + + /** + * Prop to show the arc labels in percentage format + */ + showLabelsInPercent?: boolean; + + /** + * Prop to hide the arc labels + */ + hideLabels?: boolean; + + /** + * Call to provide customized styling that will layer on top of the variant rules. + */ + styles?: PieStyles; + + /** + * Additional CSS class(es) to apply to the Chart. + */ + className?: string; +} + +export interface PieStyles { + /** + * Style set for the card header component root + */ + root: string; + + /** + * Style set for the inside donut string + */ + insideDonutString: string; +} diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/Pie/index.ts b/packages/charts/react-charts-preview/library/src/components/DonutChart/Pie/index.ts new file mode 100644 index 0000000000000..8cacde6d95481 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/Pie/index.ts @@ -0,0 +1,2 @@ +export * from './Pie'; +export * from './Pie.types'; diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/Pie/usePieStyles.styles.ts b/packages/charts/react-charts-preview/library/src/components/DonutChart/Pie/usePieStyles.styles.ts new file mode 100644 index 0000000000000..c3b61f98c9315 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/Pie/usePieStyles.styles.ts @@ -0,0 +1,46 @@ +import { PieProps, PieStyles } from './Pie.types'; +import { tokens, typographyStyles } from '@fluentui/react-theme'; +import { HighContrastSelectorBlack } from '../../../utilities/index'; + +import { makeStyles, mergeClasses } from '@griffel/react'; +import type { SlotClassNames } from '@fluentui/react-utilities'; + +/** + * @internal + */ +export const donutPieClassNames: SlotClassNames = { + root: 'fui-donut-pie__root', + insideDonutString: 'fui-donut-pie__insideDonutString', +}; + +/** + * Base Styles + */ +const useStyles = makeStyles({ + root: {}, + insideDonutString: { + ...typographyStyles.title2, + fill: tokens.colorNeutralForeground1, + [HighContrastSelectorBlack]: { + fill: 'rgb(179, 179, 179)', + }, + }, +}); + +/** + * Apply styling to the Pie inside donut chart component + */ +export const usePieStyles_unstable = (props: PieProps): PieStyles => { + const { className } = props; + + const baseStyles = useStyles(); + return { + root: mergeClasses(donutPieClassNames.root, baseStyles.root, className, props.styles?.root), + insideDonutString: mergeClasses( + donutPieClassNames.insideDonutString, + baseStyles.insideDonutString, + className, + props.styles?.insideDonutString, + ), + }; +}; diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/__snapshots__/DonutChart.test.tsx.snap b/packages/charts/react-charts-preview/library/src/components/DonutChart/__snapshots__/DonutChart.test.tsx.snap new file mode 100644 index 0000000000000..c52bd9f49cffa --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/__snapshots__/DonutChart.test.tsx.snap @@ -0,0 +1,2784 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DonutChart - mouse events Should render callout correctly on mouseover 1`] = ` +
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+
+ +
+
+
+
+
+ 2020/04/30 +
+
+ 20,000 +
+
+
+
+
+
+
+
+
+ + + +
+
+
+
+
+`; + +exports[`DonutChart - mouse events Should render customized callout on mouseover 1`] = ` +
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+
+ +
+
+
+
+
+ 2020/04/30 +
+
+ 20,000 +
+
+
+
+
+
+
+
+
+ + + +
+
+
+
+
+`; + +exports[`DonutChart snapShot testing Should elevate all smaller values to minimums 1`] = ` +
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+ + + +
+
+
+
+
+`; + +exports[`DonutChart snapShot testing Should render arc labels 1`] = ` +
+
+
+ + + + + + 20.0k + + + + + + 39.0k + + + + + + 45.0k + + + + +
+
+
+
+
+ + + +
+
+
+
+
+`; + +exports[`DonutChart snapShot testing Should render arc labels in percentage format 1`] = ` +
+
+
+ + + + + + 19% + + + + + + 38% + + + + + + 43% + + + + +
+
+
+
+
+ + + +
+
+
+
+
+`; + +exports[`DonutChart snapShot testing renders DonutChart correctly 1`] = ` +
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+ + + +
+
+
+
+
+`; + +exports[`DonutChart snapShot testing renders DonutChart correctly without color points 1`] = ` +
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+ + + +
+
+
+
+
+`; + +exports[`DonutChart snapShot testing renders enabledLegendsWrapLines correctly 1`] = ` +
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+ + + +
+
+
+
+
+`; + +exports[`DonutChart snapShot testing renders hideLegend correctly 1`] = ` +
+
+
+ + + + + + + + + + + + + +
+
+
+
+`; + +exports[`DonutChart snapShot testing renders hideTooltip correctly 1`] = ` +
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+ + + +
+
+
+
+
+`; + +exports[`DonutChart snapShot testing renders value inside onf the pie 1`] = ` +
+
+
+ + + + + + + + + + + + + 1,000 + + + +
+
+
+
+
+ + + +
+
+
+
+
+`; diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/__snapshots__/DonutChartRTL.test.tsx.snap b/packages/charts/react-charts-preview/library/src/components/DonutChart/__snapshots__/DonutChartRTL.test.tsx.snap new file mode 100644 index 0000000000000..28a1ece6086f1 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/__snapshots__/DonutChartRTL.test.tsx.snap @@ -0,0 +1,290 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Donut chart interactions Should hide callout on mouse leave 1`] = ` +
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+ + + +
+
+
+
+
+
+`; + +exports[`Donut chart interactions Should reflect theme change 1`] = ` +
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+ + + +
+
+
+
+
+
+
+`; diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/index.ts b/packages/charts/react-charts-preview/library/src/components/DonutChart/index.ts new file mode 100644 index 0000000000000..3d375e5ef86a5 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/index.ts @@ -0,0 +1,3 @@ +export * from './DonutChart'; +export * from './DonutChart.types'; +export * from '../../types/index'; diff --git a/packages/charts/react-charts-preview/library/src/components/DonutChart/useDonutChartStyles.styles.ts b/packages/charts/react-charts-preview/library/src/components/DonutChart/useDonutChartStyles.styles.ts new file mode 100644 index 0000000000000..7e98960a3216e --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/DonutChart/useDonutChartStyles.styles.ts @@ -0,0 +1,54 @@ +import { makeStyles, mergeClasses } from '@griffel/react'; +import { tokens, typographyStyles } from '@fluentui/react-theme'; +import { DonutChartProps, DonutChartStyles } from './index'; +import type { SlotClassNames } from '@fluentui/react-utilities'; + +/** + * @internal + */ +export const donutClassNames: SlotClassNames = { + root: 'fui-donut__root', + chart: 'fui-donut__chart', + legendContainer: 'fui-donut__legendContainer', + chartWrapper: 'fui-donut__chartWrapper', +}; + +/** + * Base Styles + */ +const useStyles = makeStyles({ + root: { + // alignItems: 'center', + ...typographyStyles.body1, + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', + }, + chart: { + boxSizing: 'content-box', + alignmentAdjust: 'center', + display: 'block', + overflow: 'visible', + }, + legendContainer: { paddingTop: tokens.spacingVerticalL }, +}); + +/** + * Apply styling to the DonutChart component + */ +export const useDonutChartStyles_unstable = (props: DonutChartProps): DonutChartStyles => { + const { className } = props; + const baseStyles = useStyles(); + + return { + root: mergeClasses(donutClassNames.root, baseStyles.root, className, props.styles?.root), + chart: mergeClasses(donutClassNames.chart, baseStyles.chart, props.styles?.chart), + legendContainer: mergeClasses( + donutClassNames.legendContainer, + baseStyles.legendContainer, + props.styles?.legendContainer, + ), + chartWrapper: donutClassNames.chartWrapper, + }; +}; diff --git a/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/HorizontalBarChart.test.tsx b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/HorizontalBarChart.test.tsx new file mode 100644 index 0000000000000..7728c38aae9e1 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/HorizontalBarChart.test.tsx @@ -0,0 +1,186 @@ +jest.mock('react-dom'); +import * as React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { ChartProps, HorizontalBarChartProps, HorizontalBarChart, HorizontalBarChartVariant } from './index'; +import toJson from 'enzyme-to-json'; +import * as renderer from 'react-test-renderer'; +import { act } from 'react-test-renderer'; + +// Wrapper of the HorizontalBarChart to be tested. +let wrapper: ReactWrapper | undefined; + +function sharedAfterEach() { + if (wrapper) { + wrapper.unmount(); + wrapper = undefined; + } +} + +export const chartPoints: ChartProps[] = [ + { + chartTitle: 'one', + chartData: [ + { + legend: 'one', + horizontalBarChartdata: { x: 1543, y: 15000 }, + color: '#004b50', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '94%', + }, + ], + }, + { + chartTitle: 'two', + chartData: [ + { + legend: 'two', + horizontalBarChartdata: { x: 800, y: 15000 }, + color: '#5c2d91', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '19%', + }, + ], + }, +]; + +describe('HorizontalBarChart snapShot testing', () => { + beforeEach(() => { + jest.spyOn(global.Math, 'random').mockReturnValue(0.1); + }); + afterEach(() => { + jest.spyOn(global.Math, 'random').mockRestore(); + }); + + it('Should render absolute-scale variant correctly', () => { + const component = renderer.create( + , + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('Should not render bar labels in absolute-scale variant', () => { + const component = renderer.create( + , + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); + +describe('HorizontalBarChart - basic props', () => { + afterEach(sharedAfterEach); + + it('Should mount callout when hideTootip false ', () => { + wrapper = mount(); + const hideLegendDOM = wrapper.getDOMNode().querySelectorAll('[class^="ms-Layer"]'); + expect(hideLegendDOM).toBeDefined(); + }); + + it('Should not mount callout when hideTootip true ', () => { + wrapper = mount(); + const hideLegendDOM = wrapper.getDOMNode().querySelectorAll('[class^="ms-Layer"]'); + expect(hideLegendDOM.length).toBe(0); + }); + + it('Should render onRenderCalloutPerHorizonalBar ', () => { + wrapper = mount( + + props ? ( +
+

Custom Callout Content

+
+ ) : null + } */ + />, + ); + const renderedDOM = wrapper.getDOMNode().getElementsByClassName('.onRenderCalloutPerDataPoint'); + expect(renderedDOM).toBeDefined(); + }); + + it('Should not render onRenderCalloutPerHorizonalBar ', () => { + wrapper = mount(); + const renderedDOM = wrapper.getDOMNode().getElementsByClassName('.onRenderCalloutPerHorizonalBar'); + expect(renderedDOM!.length).toBe(0); + }); +}); + +describe('Render calling with respective to props', () => { + it('No prop changes', () => { + const props = { + data: chartPoints, + height: 300, + width: 600, + }; + const component = mount(); + expect(component).toMatchSnapshot(); + component.setProps({ ...props }); + expect(component).toMatchSnapshot(); + }); + + it('prop changes', async () => { + const props = { + data: chartPoints, + height: 300, + width: 600, + }; + const component = mount(); + expect(component.props().hideTooltip).toBe(undefined); + await act(async () => { + component.setProps({ ...props, hideTooltip: true }); + }); + expect(component.props().hideTooltip).toBe(true); + component.unmount(); + }); +}); + +describe('HorizontalBarChart - mouse events', () => { + beforeEach(() => { + jest.spyOn(global.Math, 'random').mockReturnValue(0.1); + }); + afterEach(() => { + sharedAfterEach(); + jest.spyOn(global.Math, 'random').mockRestore(); + }); + + it('Should render callout correctly on mouseover', () => { + wrapper = mount(); + wrapper.find('rect').at(2).simulate('mouseover'); + const tree = toJson(wrapper, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + }); + + it('Should render customized callout on mouseover', () => { + wrapper = mount( + + props ? ( +
+
{JSON.stringify(props, null, 2)}
+
+ ) : null + } */ + />, + ); + wrapper.find('rect').at(0).simulate('mouseover'); + const tree = toJson(wrapper, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + }); +}); + +describe('Render empty chart aria label div when chart is empty', () => { + it('No empty chart aria label div rendered', () => { + wrapper = mount(); + const renderedDOM = wrapper.findWhere(node => node.prop('aria-label') === 'Graph has no data to display'); + expect(renderedDOM!.length).toBe(0); + }); + + it('Empty chart aria label div rendered', () => { + wrapper = mount(); + const renderedDOM = wrapper.findWhere(node => node.prop('aria-label') === 'Graph has no data to display'); + expect(renderedDOM!.length).toBe(1); + }); +}); diff --git a/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/HorizontalBarChart.tsx b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/HorizontalBarChart.tsx new file mode 100644 index 0000000000000..36970b07a8832 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/HorizontalBarChart.tsx @@ -0,0 +1,388 @@ +import * as React from 'react'; +import { useHorizontalBarChartStyles_unstable } from './useHorizontalBarChartStyles.styles'; +import { ChartProps, HorizontalBarChartProps, ChartDataPoint, RefArrayData, HorizontalBarChartVariant } from './index'; +import { convertToLocaleString } from '../../utilities/locale-util'; +import { formatValueWithSIPrefix, getAccessibleDataObject, useRtl } from '../../utilities/index'; +import { useId } from '@fluentui/react-utilities'; +import { tokens } from '@fluentui/react-theme'; +import { useFocusableGroup } from '@fluentui/react-tabster'; +import { ChartPopover } from '../CommonComponents/ChartPopover'; +import { FocusableTooltipText } from '../../utilities/FocusableTooltipText'; + +/** + * HorizontalBarChart is the context wrapper and container for all HorizontalBarChart content/controls, + * It has no direct style or slot opinions. + * + * HorizontalBarChart also provides API interfaces for callbacks that will occur on navigation events. + */ +export const HorizontalBarChart: React.FunctionComponent = React.forwardRef< + HTMLDivElement, + HorizontalBarChartProps +>((props, forwardedRef) => { + let _barHeight: number; + //let _classNames: IProcessedStyleSet; + const _uniqLineText: string = '_HorizontalLine_' + Math.random().toString(36).substring(7); + const _refArray: RefArrayData[] = []; + let _calloutAnchorPoint: ChartDataPoint | null; + const _isRTL: boolean = useRtl(); + const barChartSvgRef: React.RefObject = React.createRef(); + const _emptyChartId: string = useId('_HBC_empty'); + + const [hoverValue, setHoverValue] = React.useState(''); + const [lineColor, setLineColor] = React.useState(''); + const [legend, setLegend] = React.useState(''); + const [xCalloutValue, setXCalloutValue] = React.useState(''); + const [yCalloutValue, setYCalloutValue] = React.useState(''); + const [barCalloutProps, setBarCalloutProps] = React.useState(); + const [barSpacingInPercent, setBarSpacingInPercent] = React.useState(0); + const [isPopoverOpen, setPopoverOpen] = React.useState(false); + const [clickPosition, setClickPosition] = React.useState({ x: 0, y: 0 }); + + function _refCallback(element: SVGGElement, legendTitle: string | undefined): void { + _refArray.push({ index: legendTitle, refElement: element }); + } + + function _hoverOn( + event: React.MouseEvent, + hoverVal: string | number | Date, + point: ChartDataPoint, + ): void { + if ((!isPopoverOpen || legend !== point.legend!) && _calloutAnchorPoint !== point) { + _calloutAnchorPoint = point; + updatePosition(event.clientX, event.clientY); + setHoverValue(hoverVal); + setLineColor(point.color!); + setLegend(point.legend!); + setXCalloutValue(point.xAxisCalloutData!); + setYCalloutValue(point.yAxisCalloutData!); + setBarCalloutProps(point); + // ToDo - Confirm setting multiple state variables like this is performant. + } + } + + function _hoverOff(): void { + /*ToDo. To fix*/ + } + + const _handleChartMouseLeave = () => { + _calloutAnchorPoint = null; + if (isPopoverOpen) { + setPopoverOpen(false); + setHoverValue(''); + setLineColor(''); + setLegend(''); + } + }; + + const _adjustProps = (): void => { + _barHeight = props.barHeight || 12; + }; + + const _getChartDataText = (data: ChartProps) => { + /* return props.barChartCustomData ? ( +
{props.barChartCustomData(data)}
+ ) : ( */ + return _getDefaultTextData(data); + //) + }; + + function _getDefaultTextData(data: ChartProps): JSX.Element { + const { culture } = props; + const chartDataMode = props.chartDataMode || 'default'; + const chartData: ChartDataPoint = data!.chartData![0]; + const x = chartData.horizontalBarChartdata!.x; + const y = chartData.horizontalBarChartdata!.y; + + const accessibilityData = getAccessibleDataObject(data.chartDataAccessibilityData!, 'text', false); + switch (chartDataMode) { + case 'default': + return ( +
+ {convertToLocaleString(x, culture)} +
+ ); + case 'fraction': + return ( +
+ {convertToLocaleString(x, culture)} + {' / ' + convertToLocaleString(y, culture)} +
+ ); + case 'percentage': + const dataRatioPercentage = `${convertToLocaleString(Math.round((x / y) * 100), culture)}%`; + return ( +
+ {dataRatioPercentage} +
+ ); + } + } + + function _createBenchmark(data: ChartProps): JSX.Element { + const totalData = data.chartData![0].horizontalBarChartdata!.y; + const benchmarkData = data.chartData![0].data; + const benchmarkRatio = Math.round(((benchmarkData ? benchmarkData : 0) / totalData) * 100); + + const benchmarkStyles = { + left: 'calc(' + benchmarkRatio + '% - 4px)', + }; + + return ( +
+
+
+ ); + } + + /** + * This functions returns an array of elements, which form the bars + * For each bar an x value, and a width needs to be specified + * The computations are done based on percentages + * Extra margin is also provided, in the x value to provide some spacing in between the bars + */ + + function _createBars(data: ChartProps): JSX.Element[] { + const noOfBars = + data.chartData?.reduce((count: number, point: ChartDataPoint) => (count += (point.data || 0) > 0 ? 1 : 0), 0) || + 1; + const totalMarginPercent = barSpacingInPercent * (noOfBars - 1); + const defaultColors: string[] = [ + tokens.colorPaletteBlueForeground2, + tokens.colorPaletteCornflowerForeground2, + tokens.colorPaletteDarkGreenForeground2, + tokens.colorPaletteNavyForeground2, + tokens.colorPaletteDarkOrangeForeground2, + ]; + // calculating starting point of each bar and it's range + const startingPoint: number[] = []; + const total = data.chartData!.reduce( + (acc: number, point: ChartDataPoint) => + acc + (point.horizontalBarChartdata!.x ? point.horizontalBarChartdata!.x : 0), + 0, + ); + let prevPosition = 0; + let value = 0; + + let sumOfPercent = 0; + data.chartData!.map((point: ChartDataPoint, index: number) => { + const pointData = point.horizontalBarChartdata!.x ? point.horizontalBarChartdata!.x : 0; + value = (pointData / total) * 100; + if (value < 0) { + value = 0; + } else if (value < 1 && value !== 0) { + value = 1; + } + sumOfPercent += value; + + return sumOfPercent; + }); + + /** + * The %age of the space occupied by the margin needs to subtracted + * while computing the scaling ratio, since the margins are not being + * scaled down, only the data is being scaled down from a higher percentage to lower percentage + * Eg: 95% of the space is taken by the bars, 5% by the margins + * Now if the sumOfPercent is 120% -> This needs to be scaled down to 95%, not 100% + * since that's only space available to the bars + */ + const scalingRatio = sumOfPercent !== 0 ? (sumOfPercent - totalMarginPercent) / 100 : 1; + + const bars = data.chartData!.map((point: ChartDataPoint, index: number) => { + const color: string = point.color ? point.color : defaultColors[Math.floor(Math.random() * 4 + 1)]; + const pointData = point.horizontalBarChartdata!.x ? point.horizontalBarChartdata!.x : 0; + if (index > 0) { + prevPosition += value; + } + value = (pointData / total) * 100; + if (value < 0) { + value = 0; + } else if (value < 1 && value !== 0) { + value = 1 / scalingRatio; + } else { + value = value / scalingRatio; + } + startingPoint.push(prevPosition); + + const xValue = point.horizontalBarChartdata!.x; + const placeholderIndex = 1; + + // Render bar label instead of placeholder bar for absolute-scale variant + if (index === placeholderIndex && props.variant === HorizontalBarChartVariant.AbsoluteScale) { + if (props.hideLabels) { + return ; + } + + const barValue = data.chartData![0].horizontalBarChartdata!.x; + + return ( + + {formatValueWithSIPrefix(barValue)} + + ); + } + + return ( + _hoverOn(event, xValue, point) : undefined} + onFocus={point.legend !== '' ? event => _hoverOn.bind(event, xValue, point) : undefined} + role="img" + aria-label={_getAriaLabel(point)} + onBlur={_hoverOff} + onMouseLeave={_hoverOff} + className={classes.barWrapper} + tabIndex={point.legend !== '' ? 0 : undefined} + /> + ); + }); + return bars; + } + + const _getAriaLabel = (point: ChartDataPoint): string => { + const legend = point.xAxisCalloutData || point.legend; + const yValue = + point.yAxisCalloutData || + (point.horizontalBarChartdata ? `${point.horizontalBarChartdata.x}/${point.horizontalBarChartdata.y}` : 0); + return point.callOutAccessibilityData?.ariaLabel || (legend ? `${legend}, ` : '') + `${yValue}.`; + }; + + function _isChartEmpty(): boolean { + return !(props.data && props.data.length > 0); + } + + function updatePosition(newX: number, newY: number): void { + const threshold = 1; // Set a threshold for movement + const { x, y } = clickPosition; + + // Calculate the distance moved + const distance = Math.sqrt(Math.pow(newX - x, 2) + Math.pow(newY - y, 2)); + // Update the position only if the distance moved is greater than the threshold + if (distance > threshold) { + setClickPosition({ x: newX, y: newY }); + setPopoverOpen(true); + } + } + + React.useEffect(() => { + const svgWidth = barChartSvgRef?.current?.getBoundingClientRect().width || 0; + const MARGIN_WIDTH_IN_PX = 3; + if (svgWidth) { + const currentBarSpacing = (MARGIN_WIDTH_IN_PX / svgWidth) * 100; + setBarSpacingInPercent(currentBarSpacing); + } + }, [barChartSvgRef]); + + const { data } = props; + _adjustProps(); + const classes = useHorizontalBarChartStyles_unstable(props); + const focusAttributes = useFocusableGroup(); + + let datapoint: number | undefined = 0; + return !_isChartEmpty() ? ( +
+ {data!.map((points: ChartProps, index: number) => { + if (points.chartData && points.chartData![0] && points.chartData![0].horizontalBarChartdata!.x) { + datapoint = points.chartData![0].horizontalBarChartdata!.x; + } else { + datapoint = 0; + } + points.chartData![1] = { + legend: '', + horizontalBarChartdata: { + x: points.chartData![0].horizontalBarChartdata!.y - datapoint!, + y: points.chartData![0].horizontalBarChartdata!.y, + }, + color: tokens.colorBackgroundOverlay, + }; + + // Hide right side text of chart title for absolute-scale variant + const chartDataText = + props.variant === HorizontalBarChartVariant.AbsoluteScale ? null : _getChartDataText(points!); + const bars = _createBars(points!); + const keyVal = _uniqLineText + '_' + index; + // ToDo - Showtriangle property is per data series. How to account for it in the new stylesheet + /* const classes = useHorizontalBarChartStyles_unstable(props.styles!, { + width: props.width, + showTriangle: !!points!.chartData![0].data, + variant: props.variant, + }); */ + + return ( +
+
+
+ {points!.chartTitle && ( + + )} + {chartDataText} +
+ {points!.chartData![0].data && _createBenchmark(points!)} + + { + _refCallback(e, points!.chartData![0].legend); + }} + // NOTE: points.chartData![0] contains current data value + onClick={() => { + const p = points!.chartData![0]; + if (p && p.onClick) { + p.onClick(); + } + }} + > + {bars} + + +
+
+ ); + })} + +
+ ) : ( +
+ ); + //TODO validate and fix focus border for issue for popover +}); +HorizontalBarChart.displayName = 'HorizontalBarChart'; diff --git a/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/HorizontalBarChart.types.ts b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/HorizontalBarChart.types.ts new file mode 100644 index 0000000000000..37634560c1bdc --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/HorizontalBarChart.types.ts @@ -0,0 +1,192 @@ +import * as React from 'react'; +import { ChartPopoverProps } from '../CommonComponents/ChartPopover.types'; +import { ChartDataPoint, ChartProps } from './index'; + +/** + * Horizontal Bar Chart properties + * {@docCategory HorizontalBarChart} + */ +export interface HorizontalBarChartProps extends React.RefAttributes { + /** + * An array of chart data points for the Horizontal bar chart + */ + data?: ChartProps[]; + + /** + * Width of bar chart + */ + width?: number; + + /** + * Height of bar chart + * @default 15 + */ + barHeight?: number; + + /** + * Additional CSS class(es) to apply to the StackedBarChart. + */ + className?: string; + + /** + * This property tells whether to show ratio on top of stacked bar chart or not. + */ + hideRatio?: boolean[]; + + /** + * Do not show tooltips in chart + * + * @default false + */ + hideTooltip?: boolean; + + /** + * This property tells how to show data text on top right of bar chart. + * If barChartCustomData props added, then this props will be overrided. + * @default 'default' + */ + chartDataMode?: ChartDataMode; + + /** + * Call to provide customized styling that will layer on top of the variant rules. + */ + styles?: HorizontalBarChartStyles; + + /** + * Define a custom callout renderer for a horizontal bar + */ + // onRenderCalloutPerHorizontalBar?: IRenderFunction; ToDo - Need to use slots here. + + /** + * props for the callout in the chart + */ + calloutProps?: ChartPopoverProps; + + /** + * Custom text to the chart (right side of the chart) + * IChartProps will be available as props to the method prop. + * If this method not given, default values (IHorizontalDataPoint \{x,y\}) + * will be used to display the data/text based on given chartModeData prop. + */ + // barChartCustomData?: IRenderFunction; ToDo - Need to use slots here. + + /** + * The prop used to define the culture to localized the numbers + */ + culture?: string; + + /** + * Prop to define the variant of HorizontalBarChart to render + * @default HorizontalBarChartVariant.PartToWhole + */ + variant?: HorizontalBarChartVariant; + + /** + * Prop to hide the bar labels + * @default false + */ + hideLabels?: boolean; + + /** + * line color for callout + */ + color?: string; + + /** + * prop to check if benchmark data is provided + */ + showTriangle?: boolean; + + /** + * prop to render the custom callout + */ + onRenderCalloutPerHorizontalBar?: (props: ChartDataPoint) => JSX.Element | undefined; + + /** + * Define a custom callout props override + */ + customProps?: (dataPointCalloutProps: ChartDataPoint) => ChartPopoverProps; +} + +/** + * Horizontal Bar Chart styles + * {@docCategory HorizontalBarChart} + */ +export interface HorizontalBarChartStyles { + /** + * Styling for the root container + */ + root: string; + + /** + * Styling for each item in the container + */ + items: string; + + /** + * Style for the chart. + */ + chart: string; + + /** + * Style for the chart Title. + */ + chartTitle: string; + + /** + * Style for the bars. + */ + barWrapper: string; + + /** + * Style for left side text of the chart title + */ + chartTitleLeft: string; + + /** + * Style for right side text of the chart title + */ + chartTitleRight: string; + + /** + * Style for the chart data text denominator. + */ + chartDataTextDenominator: string; + + /** + * Style for the benchmark container + */ + benchmarkContainer: string; + + /** + * Style for the benchmark triangle + */ + triangle: string; + + /** + * Style for the bar labels + */ + barLabel: string; + + /** + * Style for the div containing the chart + */ + chartWrapper: string; +} + +/** + * Chart data mode for chart data text + * default: show the datapoint.x value + * fraction: show the fraction of datapoint.x/datapoint.y + * percentage: show the percentage of (datapoint.x/datapoint.y)% + * {@docCategory HorizontalBarChart} + */ +export type ChartDataMode = 'default' | 'fraction' | 'percentage'; + +/** + * {@docCategory HorizontalBarChart} + */ +export enum HorizontalBarChartVariant { + PartToWhole = 'part-to-whole', + AbsoluteScale = 'absolute-scale', +} diff --git a/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/HorizontalBarChartRTL.test.tsx b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/HorizontalBarChartRTL.test.tsx new file mode 100644 index 0000000000000..6d179e187d2ef --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/HorizontalBarChartRTL.test.tsx @@ -0,0 +1,365 @@ +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { FluentProvider } from '@fluentui/react-provider'; +import { HorizontalBarChart } from './HorizontalBarChart'; +import { getByClass, getById, testWithWait, testWithoutWait } from '../../utilities/TestUtility.test'; +import { HorizontalBarChartVariant, ChartProps } from './index'; +import { axe, toHaveNoViolations } from 'jest-axe'; + +expect.extend(toHaveNoViolations); + +const chartPoints: ChartProps[] = [ + { + chartTitle: 'one', + chartData: [ + { + legend: 'one', + horizontalBarChartdata: { x: 1543, y: 15000 }, + color: '#004b50', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '10%', + }, + ], + }, + { + chartTitle: 'two', + chartData: [ + { + legend: 'two', + horizontalBarChartdata: { x: 800, y: 15000 }, + color: '#5c2d91', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '5%', + }, + ], + }, + { + chartTitle: 'three', + chartData: [ + { + legend: 'three', + horizontalBarChartdata: { x: 8888, y: 15000 }, + color: '#a4262c', + xAxisCalloutData: '2020/04/30', + yAxisCalloutData: '59%', + }, + ], + }, +]; + +const chartPointsWithBenchMark: ChartProps[] = [ + { + chartTitle: 'one', + chartData: [{ legend: 'one', data: 50, horizontalBarChartdata: { x: 10, y: 100 }, color: '#004b50' }], + }, + { + chartTitle: 'two', + chartData: [{ legend: 'two', data: 30, horizontalBarChartdata: { x: 30, y: 200 }, color: '#5c2d91' }], + }, + { + chartTitle: 'three', + chartData: [{ legend: 'three', data: 5, horizontalBarChartdata: { x: 15, y: 50 }, color: '#a4262c' }], + }, +]; + +describe('Horizontal bar chart rendering', () => { + beforeEach(() => { + jest.spyOn(global.Math, 'random').mockReturnValue(0.1); + }); + afterEach(() => { + jest.spyOn(global.Math, 'random').mockRestore(); + }); + + testWithoutWait( + 'Should render the Horizontal bar chart legend with string data', + HorizontalBarChart, + { data: chartPoints }, + container => { + // Assert + expect(container).toMatchSnapshot(); + }, + ); +}); + +describe('Horizontal bar chart - Subcomponent bar', () => { + testWithWait( + 'Should render the bars with the specified colors', + HorizontalBarChart, + { data: chartPoints }, + container => { + // colors mentioned in the data points itself + // Assert + const bars = getByClass(container, /barWrapper/); + expect(bars[0].getAttribute('fill')).toEqual('#004b50'); + expect(bars[1].getAttribute('fill')).toEqual('var(--colorBackgroundOverlay)'); + expect(bars[2].getAttribute('fill')).toEqual('#5c2d91'); + expect(bars[3].getAttribute('fill')).toEqual('var(--colorBackgroundOverlay)'); + expect(bars[4].getAttribute('fill')).toEqual('#a4262c'); + expect(bars[5].getAttribute('fill')).toEqual('var(--colorBackgroundOverlay)'); + }, + ); + + testWithWait( + 'Should render the bar with the given height', + HorizontalBarChart, + { data: chartPoints, barHeight: 50 }, + container => { + // Assert + const bars = getByClass(container, /barWrapper/); + expect(bars).toHaveLength(6); + expect(bars[0].getAttribute('height')).toEqual('50'); + expect(bars[1].getAttribute('height')).toEqual('50'); + expect(bars[2].getAttribute('height')).toEqual('50'); + expect(bars[3].getAttribute('height')).toEqual('50'); + expect(bars[4].getAttribute('height')).toEqual('50'); + expect(bars[5].getAttribute('height')).toEqual('50'); + }, + ); + + testWithWait( + 'Should render the bars with labels hidden', + HorizontalBarChart, + { data: chartPoints, hideLabels: true }, + container => { + // Assert + expect(getByClass(container, /barLabel/i)).toHaveLength(0); + }, + ); + + testWithWait( + 'Should render the bars with left side label/Legend', + HorizontalBarChart, + { data: chartPoints }, + container => { + // Assert + expect(getByClass(container, /chartTitleLeft/i)).toHaveLength(3); + }, + ); + + testWithWait( + 'Should render the bars right side value inline with bar when variant is absolute scale', + HorizontalBarChart, + { data: chartPoints, variant: HorizontalBarChartVariant.AbsoluteScale }, + container => { + // Assert + expect(getByClass(container, /chartTitleRight/i)).toHaveLength(0); + }, + ); + + testWithWait( + 'Should render the bars right side value on top of the with bar when variant part to whole', + HorizontalBarChart, + { data: chartPoints, variant: HorizontalBarChartVariant.PartToWhole }, + container => { + // Assert + expect(getByClass(container, /chartTitleRight/i)).toHaveLength(3); + }, + ); + + testWithWait( + 'Should render the bars right side value with fractional value when chartDataMode is fraction', + HorizontalBarChart, + { data: chartPoints, chartDataMode: 'fraction' }, + container => { + //Assert + expect(getByClass(container, /fui-hbc__textDenom/i)).toHaveLength(3); + }, + ); + + testWithWait( + 'Should render the bars right side value with percentage value when chartDataMode is percentage', + HorizontalBarChart, + { data: chartPoints, chartDataMode: 'percentage' }, + container => { + // Assert + expect(screen.queryByText('10%')).not.toBeNull(); + expect(screen.queryByText('5%')).not.toBeNull(); + expect(screen.queryByText('59%')).not.toBeNull(); + }, + ); + + //This tc will fail because barChartCustomData is not defined in base file. + testWithWait( + 'Should show the custom data on right side of the chart', + HorizontalBarChart, + { + data: chartPoints, + barChartCustomData: (props: ChartProps) => + props ? ( +
+

Bar Custom Data

+
+ ) : null, + }, + container => { + expect(getByClass(container, /barChartCustomData/i)).toHaveLength(0); // ToDo - Fix this test + }, + ); +}); + +describe('Horizontal bar chart - Subcomponent Benchmark', () => { + testWithWait( + 'Should render the bar with branchmark', + HorizontalBarChart, + { data: chartPointsWithBenchMark }, + container => { + // Assert + expect(getByClass(container, /triangle/i)).toHaveLength(3); + }, + ); +}); + +describe('Horizontal bar chart - Subcomponent callout', () => { + test('Should call the handler on mouse over bar', async () => { + // ToDo - Fix this test + // Mock function to replace _hoverOn + /* const handleMouseOver = jest.fn(); + // Render the component with props + const { container } = render(); + // Wait for the component to settle if needed + await waitFor(() => { + // Find bars in the container and simulate mouse over event + const rectElements = container.querySelectorAll('rect'); + rectElements.forEach(rect => { + fireEvent.mouseOver(rect); + }); + // Assert + expect(handleMouseOver).toHaveBeenCalled(); + }); */ + }); + + testWithWait( + 'Should show the callout over the bar on mouse over', + HorizontalBarChart, + { data: chartPoints, calloutProps: { doNotLayer: true } }, + container => { + // Arrange + const bars = getByClass(container, /barWrapper/); + fireEvent.mouseOver(bars[0]); + // Assert + expect(getById(container, /toolTipcallout/i)).toBeDefined(); + }, + ); + + testWithWait( + 'Should show the custom callout over the bar on mouse over', + HorizontalBarChart, + { + data: chartPoints, + calloutProps: { doNotLayer: true }, + onRenderCalloutPerDataPoint: (props: ChartProps) => + props ? ( +
+

Custom Callout Content

+
+ ) : null, + }, + container => { + const bars = getByClass(container, /barWrapper/); + fireEvent.mouseOver(bars[0]); + // Assert + expect(getById(container, /toolTipcallout/i)).toBeDefined(); + expect(screen.queryByText('Custom Callout Content')).toBeDefined(); + }, + ); +}); + +describe('Horizontal bar chart - Screen resolution', () => { + beforeEach(() => { + jest.spyOn(global.Math, 'random').mockReturnValue(0.1); + }); + + const originalInnerWidth = global.innerWidth; + const originalInnerHeight = global.innerHeight; + afterEach(() => { + jest.spyOn(global.Math, 'random').mockRestore(); + global.innerWidth = originalInnerWidth; + global.innerHeight = originalInnerHeight; + act(() => { + global.dispatchEvent(new Event('resize')); + }); + }); + + testWithWait( + 'Should remain unchanged on zoom in', + HorizontalBarChart, + { data: chartPoints, width: 300, height: 300 }, + container => { + global.innerWidth = window.innerWidth / 2; + global.innerHeight = window.innerHeight / 2; + act(() => { + global.dispatchEvent(new Event('resize')); + }); + // Assert + expect(container).toMatchSnapshot(); + }, + ); + + testWithWait( + 'Should remain unchanged on zoom out', + HorizontalBarChart, + { data: chartPoints, width: 300, height: 300 }, + container => { + global.innerWidth = window.innerWidth * 2; + global.innerHeight = window.innerHeight * 2; + act(() => { + global.dispatchEvent(new Event('resize')); + }); + // Assert + expect(container).toMatchSnapshot(); + }, + ); +}); + +describe('Horizontal bar chart - Theme', () => { + beforeEach(() => { + jest.spyOn(global.Math, 'random').mockReturnValue(0.1); + }); + afterEach(() => { + jest.spyOn(global.Math, 'random').mockRestore(); + }); + test('Should reflect theme change', () => { + // Arrange + const { container } = render( + + + , + ); + // Assert + expect(container).toMatchSnapshot(); + }); +}); + +describe('Horizontal bar chart re-rendering', () => { + beforeEach(() => { + jest.spyOn(global.Math, 'random').mockReturnValue(0.1); + }); + afterEach(() => { + jest.spyOn(global.Math, 'random').mockRestore(); + }); + test('Should re-render the Horizontal bar chart with data', async () => { + // Arrange + const { container, rerender } = render(); + // Assert + expect(container).toMatchSnapshot(); + expect(getById(container, /_HBC_empty/i)).toHaveLength(1); + // Act + rerender(); + await waitFor(() => { + // Assert + expect(container).toMatchSnapshot(); + expect(getById(container, /_HBC_empty/i)).toHaveLength(0); + }); + }); +}); + +describe('Horizontal Bar Chart - axe-core', () => { + test('Should pass accessibility tests', async () => { + const { container } = render(); + let axeResults; + await act(async () => { + axeResults = await axe(container); + }); + expect(axeResults).toHaveNoViolations(); + }); +}); diff --git a/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/__snapshots__/HorizontalBarChart.test.tsx.snap b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/__snapshots__/HorizontalBarChart.test.tsx.snap new file mode 100644 index 0000000000000..d8dfc378fbc44 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/__snapshots__/HorizontalBarChart.test.tsx.snap @@ -0,0 +1,619 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HorizontalBarChart - mouse events Should render callout correctly on mouseover 1`] = ` +
+
+
+
+
+ Array [ + + one + , +
+
+ 1,543 +
+
+ + + + + + +
+
+
+
+
+
+ Array [ + + two + , +
+
+ 800 +
+
+ + + + + + +
+
+
+
+`; + +exports[`HorizontalBarChart - mouse events Should render customized callout on mouseover 1`] = ` +
+
+
+
+
+ Array [ + + one + , +
+
+ 1,543 +
+
+ + + + + + +
+
+
+
+
+
+ Array [ + + two + , +
+
+ 800 +
+
+ + + + + + +
+
+
+
+`; + +exports[`HorizontalBarChart snapShot testing Should not render bar labels in absolute-scale variant 1`] = ` +
+
+
+
+
+ + one + +
+
+ + + + + + +
+
+
+
+
+
+ + two + +
+
+ + + + + + +
+
+
+
+`; + +exports[`HorizontalBarChart snapShot testing Should render absolute-scale variant correctly 1`] = ` +
+
+
+
+
+ + one + +
+
+ + + + + 1.5k + + + +
+
+
+
+
+
+ + two + +
+
+ + + + + 800 + + + +
+
+
+
+`; + +exports[`Render calling with respective to props No prop changes 1`] = `ReactWrapper {}`; + +exports[`Render calling with respective to props No prop changes 2`] = `ReactWrapper {}`; diff --git a/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/__snapshots__/HorizontalBarChartRTL.test.tsx.snap b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/__snapshots__/HorizontalBarChartRTL.test.tsx.snap new file mode 100644 index 0000000000000..71c6572834b53 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/__snapshots__/HorizontalBarChartRTL.test.tsx.snap @@ -0,0 +1,1038 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Horizontal bar chart - Screen resolution Should remain unchanged on zoom in 1`] = ` +
+
+
+
+
+
+ + one + +
+
+ 1,543 +
+
+ + + + + + +
+
+
+
+
+
+ + two + +
+
+ 800 +
+
+ + + + + + +
+
+
+
+
+
+ + three + +
+
+ 8,888 +
+
+ + + + + + +
+
+
+
+
+`; + +exports[`Horizontal bar chart - Screen resolution Should remain unchanged on zoom out 1`] = ` +
+
+
+
+
+
+ + one + +
+
+ 1,543 +
+
+ + + + + + +
+
+
+
+
+
+ + two + +
+
+ 800 +
+
+ + + + + + +
+
+
+
+
+
+ + three + +
+
+ 8,888 +
+
+ + + + + + +
+
+
+
+
+`; + +exports[`Horizontal bar chart - Theme Should reflect theme change 1`] = ` +
+
+
+
+
+
+
+ + one + +
+
+ 1,543 +
+
+ + + + + + +
+
+
+
+
+
+ + two + +
+
+ 800 +
+
+ + + + + + +
+
+
+
+
+
+ + three + +
+
+ 8,888 +
+
+ + + + + + +
+
+
+
+
+
+`; + +exports[`Horizontal bar chart re-rendering Should re-render the Horizontal bar chart with data 1`] = ` +
+ +`; + +exports[`Horizontal bar chart re-rendering Should re-render the Horizontal bar chart with data 2`] = ` +
+
+
+
+
+
+ + one + +
+
+ 1,543 +
+
+ + + + + + +
+
+
+
+
+
+ + two + +
+
+ 800 +
+
+ + + + + + +
+
+
+
+
+
+ + three + +
+
+ 8,888 +
+
+ + + + + + +
+
+
+
+
+`; + +exports[`Horizontal bar chart rendering Should render the Horizontal bar chart legend with string data 1`] = ` +
+
+
+
+
+
+ + one + +
+
+ 1,543 +
+
+ + + + + + +
+
+
+
+
+
+ + two + +
+
+ 800 +
+
+ + + + + + +
+
+
+
+
+
+ + three + +
+
+ 8,888 +
+
+ + + + + + +
+
+
+
+
+`; diff --git a/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/index.ts b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/index.ts new file mode 100644 index 0000000000000..eda906224ca9f --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/index.ts @@ -0,0 +1,3 @@ +export * from './HorizontalBarChart'; +export * from './HorizontalBarChart.types'; +export * from '../../types/index'; diff --git a/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/useHorizontalBarChartStyles.styles.ts b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/useHorizontalBarChartStyles.styles.ts new file mode 100644 index 0000000000000..30d4dd2934b02 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/HorizontalBarChart/useHorizontalBarChartStyles.styles.ts @@ -0,0 +1,152 @@ +import { makeStyles, mergeClasses, shorthands } from '@fluentui/react-components'; +import { tokens, typographyStyles } from '@fluentui/react-theme'; +import { HorizontalBarChartProps, HorizontalBarChartStyles, HorizontalBarChartVariant } from './index'; +import type { SlotClassNames } from '@fluentui/react-utilities'; + +/** + * @internal + */ +export const hbcClassNames: SlotClassNames = { + root: 'fui-hbc__root', + items: 'fui-hbc__items', + chart: 'fui-hbc__chart', + chartTitle: 'fui-hbc__chartTitle', + barWrapper: 'fui-hbc__barWrapper', + chartTitleLeft: 'fui-hbc__chartTitleLeft', + chartTitleRight: 'fui-hbc__chartTitleRight', + chartDataTextDenominator: 'fui-hbc__textDenom', + benchmarkContainer: 'fui-hbc__benchmark', + triangle: 'fui-hbc__triangle', + barLabel: 'fui-hbc__barLabel', + chartWrapper: 'fui-hbc__chartWrapper', +}; + +/** + * Base Styles + */ +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + width: '100%', // Support custom width + }, + items10pMargin: { + marginBottom: tokens.spacingVerticalMNudge, + }, + items16pMargin: { + marginBottom: tokens.spacingVerticalL, + }, + chart: { + width: '100%', + height: '12px', // Support custom bar height + display: 'block', + overflow: 'visible', + }, + barWrapper: {}, + chartTitle: { + ...typographyStyles.caption1, + display: 'flex', + justifyContent: 'space-between', + }, + chartTitleLeft: { + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + display: 'block', + color: tokens.colorNeutralForeground1, + }, + chartTitleLeft4pMargin: { + marginBottom: '4px', + }, + chartTitleLeft5pMargin: { + marginBottom: '5px', + }, + chartTitleRight: { + ...typographyStyles.body1Strong, + color: tokens.colorNeutralForeground1, + }, + chartDataTextDenominator: { + ...typographyStyles.body1, + color: tokens.colorNeutralForeground1, + }, + benchmarkContainer: { + position: 'relative', + height: '7px', + marginTop: '-3px', + marginBottom: '-1px', + }, + triangle: { + width: '0', + height: '0', + ...shorthands.borderLeft('4px', 'solid', 'transparent'), + ...shorthands.borderRight('4px', 'solid', 'transparent'), + ...shorthands.borderTop('7px', 'solid'), + borderTopColor: tokens.colorPaletteBlueBorderActive, + marginBottom: tokens.spacingVerticalXS, + position: 'absolute', + }, + barLabel: { + ...typographyStyles.caption1Strong, + fill: tokens.colorNeutralForeground1, + }, + chartWrapper40ppadding: { + paddingRight: '40p', + }, + chartWrapper0ppadding: { + paddingRight: tokens.spacingHorizontalNone, + }, +}); + +/** + * Apply styling to the Carousel slots based on the state + */ +export const useHorizontalBarChartStyles_unstable = (props: HorizontalBarChartProps): HorizontalBarChartStyles => { + const { className, showTriangle, variant, hideLabels } = props; // ToDo - width, barHeight is non enumerable. Need to be used inline. + const baseStyles = useStyles(); + + return { + root: mergeClasses(hbcClassNames.root, baseStyles.root, className, props.styles?.root), + items: mergeClasses( + hbcClassNames.items, + showTriangle || variant === HorizontalBarChartVariant.AbsoluteScale + ? baseStyles.items16pMargin + : baseStyles.items10pMargin, + props.styles?.items, + ), + chart: mergeClasses(hbcClassNames.chart, baseStyles.chart, props.styles?.chart), + chartTitle: mergeClasses(hbcClassNames.chartTitle, baseStyles.chartTitle, props.styles?.chartTitle), + barWrapper: mergeClasses(hbcClassNames.barWrapper, baseStyles.barWrapper, props.styles?.barWrapper), + chartTitleLeft: mergeClasses( + hbcClassNames.chartTitleLeft, + baseStyles.chartTitleLeft, + variant === HorizontalBarChartVariant.AbsoluteScale + ? baseStyles.chartTitleLeft4pMargin + : baseStyles.chartTitleLeft5pMargin, + props.styles?.chartTitleLeft, + ), + chartTitleRight: mergeClasses( + hbcClassNames.chartTitleRight, + baseStyles.chartTitleRight, + props.styles?.chartTitleRight, + ), + chartDataTextDenominator: mergeClasses( + hbcClassNames.chartDataTextDenominator, + baseStyles.chartDataTextDenominator, + props.styles?.chartDataTextDenominator, + ), + benchmarkContainer: mergeClasses( + hbcClassNames.benchmarkContainer, + baseStyles.benchmarkContainer, + props.styles?.benchmarkContainer, + ), + triangle: mergeClasses(hbcClassNames.triangle, baseStyles.triangle, props.styles?.triangle), + barLabel: mergeClasses(hbcClassNames.barLabel, baseStyles.barLabel, props.styles?.barLabel), + chartWrapper: mergeClasses( + hbcClassNames.chartWrapper, + variant === HorizontalBarChartVariant.AbsoluteScale && !hideLabels + ? baseStyles.chartWrapper40ppadding + : baseStyles.chartWrapper0ppadding, + props.styles?.chartWrapper, + ), + }; +}; diff --git a/packages/charts/react-charts-preview/library/src/components/Legends/Legends.test.tsx b/packages/charts/react-charts-preview/library/src/components/Legends/Legends.test.tsx new file mode 100644 index 0000000000000..a8335d3014bba --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/Legends/Legends.test.tsx @@ -0,0 +1,236 @@ +jest.mock('react-dom'); +import * as React from 'react'; +import * as renderer from 'react-test-renderer'; +import { Legends } from './index'; +import { LegendState } from './Legends'; +import { mount, ReactWrapper } from 'enzyme'; +import { LegendsProps } from './Legends.types'; +import { render, act } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; + +expect.extend(toHaveNoViolations); + +// Wrapper of the Legends to be tested. +let wrapper: ReactWrapper | undefined; + +function sharedAfterEach() { + if (wrapper) { + wrapper.unmount(); + wrapper = undefined; + } + + // Do this after unmounting the wrapper to make sure if any timers cleaned up on unmount are + // cleaned up in fake timers world + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((global.setTimeout as any).mock) { + jest.useRealTimers(); + } +} + +const legends = [ + { + title: 'Legend 1', + color: '#FF0000', + }, + { + title: 'Legend 2', + color: '#000000', + }, + { + title: 'Legend 3', + color: '#008000', + }, + { + title: 'Legend 4', + color: '#0000ff', + }, + { + title: 'Legend 5', + color: '#191970', + }, + { + title: 'Legend 6', + color: '#E4E3E9', + }, + { + title: 'Legend 7', + color: '#013220', + }, + { + title: 'Legend 8', + color: '#00008B', + }, + { + title: 'Legend 9', + color: '#FFA500', + }, + { + title: 'Legend 10', + color: '#301934', + }, + { + title: 'Legend 11', + color: '#Ffffed', + }, + { + title: 'Legend 12', + color: '#90ee90', + }, + { + title: 'Legend 13', + color: '#FFA500', + }, + { + title: 'Legend 14', + color: '#008080', + }, + { + title: 'Legend 15', + color: '#008080', + }, + { + title: 'Legend 16', + color: 'FF0000', + }, + { + title: 'Legend 17', + color: '#FFFFFF', + }, +]; + +const styles = { + rect: { + borderRadius: '3px', + }, +}; + +const overflowProps = { + styles: { + item: { border: `1px dotted #008000` }, + root: {}, + overflowButton: { backgroundColor: '#Ffe536' }, + }, +}; + +const focusZonePropsInHoverCard = { + 'aria-label': 'Legend 1 selected', +}; + +describe('Legends snapShot testing', () => { + it('renders Legends correctly', () => { + const component = renderer.create(); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders allowFocusOnLegends correctly', () => { + const component = renderer.create(); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders canSelectMultipleLegends correctly', () => { + const component = renderer.create(); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders styles correctly', () => { + const component = renderer.create(); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); + +describe('Legends - basic props', () => { + afterEach(sharedAfterEach); + + it('Should not mount legends when empty', () => { + wrapper = mount(); + const legend = wrapper.getDOMNode().querySelectorAll('[class^="legendContainer"]'); + expect(legend!.length).toBe(0); + }); + + it('Should mount legends when not empty', () => { + wrapper = mount(); + const legend = wrapper.getDOMNode().querySelectorAll('[class^="legendContainer"]'); + expect(legend).toBeDefined(); + }); + + it('Should mount Overflow Button when not empty', () => { + wrapper = mount(); + const overflowBtnText = wrapper.getDOMNode().querySelectorAll('[class^="ms-OverflowSet-overflowButton"]'); + expect(overflowBtnText).toBeDefined(); + }); + + it('Should not mount Overflow when empty', () => { + wrapper = mount(); + const overflowBtn = wrapper.getDOMNode().querySelectorAll('[class^="ms-OverflowSet-overflowButton"]'); + expect(overflowBtn!.length).toBe(0); + }); + + it('Should be not able to select multiple Legends', () => { + wrapper = mount(); + const canSelectMultipleLegends = wrapper + .getDOMNode() + .querySelector('[class^="legend"]') + ?.getAttribute('canSelectMultipleLegends'); + expect(canSelectMultipleLegends).toBeFalsy(); + }); + + it('Should render data-is-focusable correctly', () => { + wrapper = mount(); + }); +}); + +describe('Render calling with respective to props', () => { + //To Do - This tc will be need to revisit because the logic is not correct. + it('No prop changes', () => { + const props = { + legends, + }; + const component = mount(); + expect(component).toMatchSnapshot(); + component.setProps({ ...props }); + expect(component).toMatchSnapshot(); + }); + it('prop changes', () => { + const props = { + legends, + allowFocusOnLegends: true, + focusZonePropsInHoverCard, + overflowProps, + overflowText: 'OverFlow Items', + }; + const component = mount(); + component.setProps({ ...props, hideTooltip: true }); + const renderedDOM = component.findWhere(node => node.prop('hideTooltip') === true); + expect(renderedDOM!.length).toBe(1); + }); +}); + +describe('Legends - multi Legends', () => { + afterEach(sharedAfterEach); + it('Should render defaultSelectedLegends', () => { + wrapper = mount( + , + ); + const renderedLegends = wrapper.getDOMNode().querySelectorAll('button[aria-selected="true"]'); + expect(renderedLegends?.length).toBe(2); + }); +}); + +describe('Legends - axe-core', () => { + test('Should pass accessibility tests', async () => { + const { container } = render(); + let axeResults; + await act(async () => { + axeResults = await axe(container); + }); + expect(axeResults).toHaveNoViolations(); + }); +}); diff --git a/packages/charts/react-charts-preview/library/src/components/Legends/Legends.tsx b/packages/charts/react-charts-preview/library/src/components/Legends/Legends.tsx new file mode 100644 index 0000000000000..1952f0320081f --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/Legends/Legends.tsx @@ -0,0 +1,343 @@ +import * as React from 'react'; + +import { Button } from '@fluentui/react-button'; +import { Legend, LegendsProps, LegendShape } from './Legends.types'; +import { Shape } from './shape'; +import { useLegendStyles_unstable } from './useLegendsStyles.styles'; +import { Overflow, OverflowItem } from '@fluentui/react-overflow'; +import { useFocusableGroup, useArrowNavigationGroup } from '@fluentui/react-tabster'; +import { OverflowMenu } from './OverflowMenu'; +import { tokens } from '@fluentui/react-theme'; + +// This is an internal interface used for rendering the legends with unique key +interface LegendItem extends React.ButtonHTMLAttributes { + name?: string; + title: string; + action: VoidFunction; + hoverAction: VoidFunction; + onMouseOutAction: VoidFunction; + color: string; + shape?: LegendShape; + key: number; + opacity?: number; + stripePattern?: boolean; + isLineLegendInBarChart?: boolean; +} + +interface LegendMap { + [key: string]: boolean; +} + +export interface LegendState { + activeLegend: string; + /** Set of legends selected, both for multiple selection and single selection */ + selectedLegends: LegendMap; +} +export const Legends: React.FunctionComponent = React.forwardRef( + (props, forwardedRef) => { + /** Boolean variable to check if one or more legends are selected */ + let _isLegendSelected = false; + + // set states separately for each instance of the component + const [activeLegend, setActiveLegend] = React.useState(''); + const [selectedLegends, setSelectedLegends] = React.useState({}); + const focusAttributes = useFocusableGroup(); + const arrowAttributes = useArrowNavigationGroup({ axis: 'horizontal', memorizeCurrent: true }); + + React.useEffect(() => { + let defaultSelectedLegends = {}; + if (props.canSelectMultipleLegends) { + defaultSelectedLegends = + props.defaultSelectedLegends?.reduce((combinedDict, key) => ({ [key]: true, ...combinedDict }), {}) || {}; + } else if (props.defaultSelectedLegend) { + defaultSelectedLegends = { [props.defaultSelectedLegend]: true }; + } + + setSelectedLegends(defaultSelectedLegends); + }, [props.canSelectMultipleLegends, props.defaultSelectedLegend, props.defaultSelectedLegends]); + + _isLegendSelected = Object.keys(selectedLegends).length > 0; + const dataToRender = _generateData(); + const { overflowStyles, allowFocusOnLegends = true, canSelectMultipleLegends = false } = props; + const classes = useLegendStyles_unstable(props); + const itemIds = dataToRender.map((_item, index) => index.toString()); + const overflowHoverCardLegends: JSX.Element[] = []; + props.legends.map((legend, index) => { + const hoverCardElement = _renderButton(legend, index); + overflowHoverCardLegends.push(hoverCardElement); + }); + const overflowString = props.overflowText ? props.overflowText : 'more'; + return props.enabledWrapLines ? renderWrappedLegends() : renderLegends(); + + function renderLegends(): JSX.Element { + return ( +
+ +
+ {dataToRender.map((item, id) => ( + + {_renderButton(item)} + + ))} + +
+
+
+ ); + } + + function renderWrappedLegends(): JSX.Element { + return ( +
+
+ {dataToRender.map((item, id) => ( +
+ {_renderButton(item)} +
+ ))} +
+
+ ); + } + + function _generateData(): LegendItem[] { + const { /*allowFocusOnLegends = true,*/ shape } = props; + const dataItems: LegendItem[] = props.legends.map((legend: Legend, index: number) => { + return { + /* ...(allowFocusOnLegends && { + nativeButtonProps: getIntrinsicElementProps( + 'div', + { + legend, + ...buttonProperties, + }, + ['title'], + ), + 'aria-setsize': props.legends.length, + 'aria-posinset': index + 1, + }), */ + title: legend.title, + action: legend.action!, + hoverAction: legend.hoverAction!, + onMouseOutAction: legend.onMouseOutAction!, + color: legend.color, + shape: shape ? shape : legend.shape, + stripePattern: legend.stripePattern, + isLineLegendInBarChart: legend.isLineLegendInBarChart, + opacity: legend.opacity, + key: index, + }; + }); + return dataItems; + } + + /** + * This function will get called when there is an ability to + * select multiple legends + * @param legend ILegend + */ + function _canSelectMultipleLegends(legend: Legend): { [key: string]: boolean } { + let legendsSelected = { ...selectedLegends }; + if (legendsSelected[legend.title]) { + // Delete entry for the deselected legend to make + // the number of keys equal to the number of selected legends + delete legendsSelected[legend.title]; + } else { + legendsSelected[legend.title] = true; + // Clear set if all legends are selected + if (Object.keys(legendsSelected).length === props.legends.length) { + legendsSelected = {}; + } + } + setSelectedLegends(legendsSelected); + return legendsSelected; + } + + /** + * This function will get called when there is + * ability to select only single legend + * @param legend ILegend + */ + + function _canSelectOnlySingleLegend(legend: Legend): boolean { + if (selectedLegends[legend.title]) { + setSelectedLegends({}); + return false; + } else { + setSelectedLegends({ [legend.title]: true }); + return true; + } + } + + function _onClick(legend: Legend, event: React.MouseEvent): void { + const { canSelectMultipleLegends = false } = props; + let selectedLegends: string[] = []; + if (canSelectMultipleLegends) { + const nextSelectedLegends = _canSelectMultipleLegends(legend); + selectedLegends = Object.keys(nextSelectedLegends); + } else { + const isSelected = _canSelectOnlySingleLegend(legend); + selectedLegends = isSelected ? [legend.title] : []; + } + props.onChange?.(selectedLegends, event, legend); + legend.action?.(); + } + + function _onHoverOverLegend(legend: Legend) { + if (legend.hoverAction) { + setActiveLegend(legend.title); + legend.hoverAction(); + } + } + + function _onLeave(legend: Legend) { + if (legend.onMouseOutAction) { + setActiveLegend(''); + legend.onMouseOutAction(); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function _renderButton(data: any, index?: number) { + const { allowFocusOnLegends = true } = props; + const legend: Legend = { + title: data.title, + color: data.color, + shape: data.shape, + action: data.action, + hoverAction: data.hoverAction, + onMouseOutAction: data.onMouseOutAction, + stripePattern: data.stripePattern, + isLineLegendInBarChart: data.isLineLegendInBarChart, + opacity: data.opacity, + }; + const color = _getColor(legend.title, legend.color); + const onClickHandler = (event: React.MouseEvent) => { + _onClick(legend, event); + }; + const onHoverHandler = () => { + _onHoverOverLegend(legend); + }; + const onMouseOut = () => { + _onLeave(legend); + }; + const shape = _getShape(legend, color); + return ( + + ); + } + + function _getShape(legend: Legend, color: string): React.ReactNode | string { + const svgParentProps: React.SVGAttributes = { + className: classes.shape, + }; + const svgChildProps: React.SVGAttributes = { + fill: color, + strokeWidth: 2, + stroke: legend.color, + }; + return ( + + ); + } + + function _getColor(title: string, color: string): string { + let legendColor = color; + // if one or more legends are selected + if (_isLegendSelected) { + // if the given legend (title) is one of the selected legends + if (selectedLegends[title]) { + legendColor = color; + } + // if the given legend is unselected + else { + legendColor = tokens.colorNeutralBackground1; + } + } + // if no legend is selected + else { + // if the given legend is hovered + // or none of the legends is hovered + if (activeLegend === title || activeLegend === '') { + legendColor = color; + } + // if there is a hovered legend but the given legend is not the one + else { + legendColor = tokens.colorNeutralBackground1; + } + } + return legendColor; + } + }, +); +Legends.displayName = 'Legends'; diff --git a/packages/charts/react-charts-preview/library/src/components/Legends/Legends.types.ts b/packages/charts/react-charts-preview/library/src/components/Legends/Legends.types.ts new file mode 100644 index 0000000000000..f6987f2510dfc --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/Legends/Legends.types.ts @@ -0,0 +1,207 @@ +import * as React from 'react'; +import { CustomPoints, Points } from '../../utilities/utilities'; + +/** + * @public + * Legends styles + * {@docCategory Legends} + */ +export interface LegendsStyles { + /** + * Style set for the root of the legend component + */ + root?: string; + + /** + * Style set for Legend. This is a wrapping class for text of legend and the rectange box that represents a legend + */ + legend?: string; + + /** + * Style set for the rectangle that represents a legend + */ + rect?: string; + + /** + * styles set for the shape that represents a legend + */ + shape?: string; + + /** + * Style set for the triangle that represents a legend + */ + triangle?: string; + + /** + * Style for the legend text + */ + text?: string; + + /** + * Style for the legend text + */ + hoverChange?: string; + + /** + * Style for the area that is resizable + */ + resizableArea?: string; +} + +/** + * @public + * ILegend interface + * {@docCategory Legends} + */ +export interface Legend { + /** + * Defines the title of the legend + */ + title: string; + + /** + * Defines the function that is executed on clicking this legend + */ + action?: VoidFunction; + + /** + * Defines the function that is executed upon hovering over the legend + */ + hoverAction?: VoidFunction; + + /** + * Defines the function that is executed upon moving the mouse away from the legend + */ + onMouseOutAction?: (isLegendFocused?: boolean) => void; + + /** + * The color for the legend + */ + color: string; + + /** + * The opacity of the legend color + */ + opacity?: number; + + /** + * The shape for the legend + */ + shape?: LegendShape; + + /** + * Indicated whether or not to apply stripe pattern + */ + stripePattern?: boolean; + + /** + * Indicates if the legend belongs to a line in the Bar Chart + */ + isLineLegendInBarChart?: boolean; + + /* + * native button props for the legend button + */ + nativeButtonProps?: React.ButtonHTMLAttributes; +} + +/** + * @public + * Legend style properties + * {@docCategory Legends} + */ +export interface LegendStyleProps { + className?: string; + colorOnSelectedState?: string; + borderColor?: string; + opacity?: number; + overflow?: boolean; + stripePattern?: boolean; + isLineLegendInBarChart?: boolean; +} + +/** + * @public + * Legend properties + * {@docCategory Legends} + */ +export interface LegendsProps { + /** + * Prop that takes list of legends + */ + legends: Legend[]; + + /** + * Additional CSS class(es) to apply to the legends component. + */ + className?: LegendsStyles; + + /** + * Call to provide customized styling that will layer on top of the variant rules. + */ + styles?: LegendsStyles; + + /** + * This prop makes the legends component align itself to the center in the container it is sitting in + */ + centerLegends?: boolean; + + /** + * Enable the legends to wrap lines if there is not enough space to show all legends on a single line + */ + enabledWrapLines?: boolean; + + /** + * style for the overflow component + */ + overflowStyles?: React.CSSProperties; + + /** + * text for overflow legends string + */ + overflowText?: string; + + /** + * prop that decides if legends are focusable + * @default true + */ + allowFocusOnLegends?: boolean; + + /** + * prop that decide if we can select multiple legends or single legend at a time + * @default false + */ + canSelectMultipleLegends?: boolean; + + /** + * Callback issued when the selected option changes. + */ + onChange?: (selectedLegends: string[], event: React.MouseEvent, currentLegend?: Legend) => void; + + /** + * Keys (title) that will be initially used to set selected items. + * This prop is used for multiSelect scenarios. + * In other cases, defaultSelectedLegend should be used. + */ + defaultSelectedLegends?: string[]; + + /** + * Key that will be initially used to set selected item. + * This prop is used for singleSelect scenarios. + */ + defaultSelectedLegend?: string; + + /** + * The shape for the legend. + */ + shape?: LegendShape; +} + +/** + * @public + * The shape for the legend + * default: show the rect legend + * triangle: show the triangle legend + * {@docCategory Legends} + */ +export type LegendShape = 'default' | 'triangle' | keyof typeof Points | keyof typeof CustomPoints; diff --git a/packages/charts/react-charts-preview/library/src/components/Legends/OverflowMenu.tsx b/packages/charts/react-charts-preview/library/src/components/Legends/OverflowMenu.tsx new file mode 100644 index 0000000000000..15d871ffe5f86 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/Legends/OverflowMenu.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-menu'; +import { MenuButton } from '@fluentui/react-button'; +import { useOverflowMenu } from '@fluentui/react-overflow'; + +export const OverflowMenu: React.FC<{ itemIds: string[]; title: string; items: JSX.Element[] }> = ({ + itemIds, + title, + items, +}) => { + const { ref, overflowCount, isOverflowing } = useOverflowMenu(); + let displayLabel = title; + displayLabel = title === '' ? `+${overflowCount} items` : `+${overflowCount} ${title}`; + + if (!isOverflowing) { + return null; + } + const remainingItemsCount = itemIds.length - overflowCount; + const menuList = []; + for (let i = remainingItemsCount; i < itemIds.length; i++) { + menuList.push( + + {items[i]} + , + ); + } + return ( + + + {displayLabel} + + + + {menuList} + + + ); +}; diff --git a/packages/charts/react-charts-preview/library/src/components/Legends/__snapshots__/Legends.test.tsx.snap b/packages/charts/react-charts-preview/library/src/components/Legends/__snapshots__/Legends.test.tsx.snap new file mode 100644 index 0000000000000..ec178606502eb --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/Legends/__snapshots__/Legends.test.tsx.snap @@ -0,0 +1,2945 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Legends snapShot testing renders Legends correctly 1`] = ` +
+
+ + + + + + + + + + + + + + + + + +
+
+`; + +exports[`Legends snapShot testing renders allowFocusOnLegends correctly 1`] = ` +
+
+ + + + + + + + + + + + + + + + + +
+
+`; + +exports[`Legends snapShot testing renders canSelectMultipleLegends correctly 1`] = ` +
+
+ + + + + + + + + + + + + + + + + +
+
+`; + +exports[`Legends snapShot testing renders styles correctly 1`] = ` +
+
+ + + + + + + + + + + + + + + + + +
+
+`; + +exports[`Render calling with respective to props No prop changes 1`] = `ReactWrapper {}`; + +exports[`Render calling with respective to props No prop changes 2`] = `ReactWrapper {}`; diff --git a/packages/charts/react-charts-preview/library/src/components/Legends/index.ts b/packages/charts/react-charts-preview/library/src/components/Legends/index.ts new file mode 100644 index 0000000000000..98292896cffc8 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/Legends/index.ts @@ -0,0 +1,3 @@ +export * from './Legends'; +export * from './Legends.types'; +export * from './shape'; diff --git a/packages/charts/react-charts-preview/library/src/components/Legends/shape.tsx b/packages/charts/react-charts-preview/library/src/components/Legends/shape.tsx new file mode 100644 index 0000000000000..2c1fdf55d4ecf --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/Legends/shape.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { LegendShape } from './Legends.types'; +import { CustomPoints, Points } from '../../utilities/utilities'; + +export interface ShapeProps { + svgProps: React.SVGAttributes; + pathProps: React.SVGAttributes; + shape: LegendShape; + classNameForNonSvg?: string; + style?: React.CSSProperties | undefined; +} + +type PointPathType = { + [key: string]: string; +}; + +const pointPath: PointPathType = { + [`${Points[Points.circle]}`]: 'M1 6 A5 5 0 1 0 12 6 M1 6 A5 5 0 0 1 12 6', + [`${Points[Points.square]}`]: 'M1 1 L12 1 L12 12 L1 12 L1 1 Z', + [`${Points[Points.triangle]}`]: 'M6 10L8.74228e-07 -1.04907e-06L12 0L6 10Z', + [`${Points[Points.pyramid]}`]: 'M6 10L8.74228e-07 -1.04907e-06L12 0L6 10Z', + [`${Points[Points.diamond]}`]: 'M2 2 L10 2 L10 10 L2 10 L2 2 Z', + [`${Points[Points.hexagon]}`]: 'M9 0H3L0 5L3 10H9L12 5L9 0Z', + [`${Points[Points.pentagon]}`]: 'M6.06061 0L0 4.21277L2.30303 11H9.69697L12 4.21277L6.06061 0Z', + [`${Points[Points.octagon]}`]: + 'M7.08333 0H2.91667L0 2.91667V7.08333L2.91667 10H7.08333L10 7.08333V2.91667L7.08333 0Z', + [`${CustomPoints[CustomPoints.dottedLine]}`]: 'M0 6 H3 M5 6 H8 M10 6 H13', +}; + +export const Shape: React.FunctionComponent = React.forwardRef( + ({ svgProps, pathProps, shape, classNameForNonSvg, style }, forwardedRef) => { + if (Object.keys(pointPath).indexOf(shape) === -1) { + return
; + } + return ( + + + + ); + }, +); diff --git a/packages/charts/react-charts-preview/library/src/components/Legends/useLegendsStyles.styles.ts b/packages/charts/react-charts-preview/library/src/components/Legends/useLegendsStyles.styles.ts new file mode 100644 index 0000000000000..896d7ba6f8b88 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/Legends/useLegendsStyles.styles.ts @@ -0,0 +1,105 @@ +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import { LegendsProps, LegendsStyles } from './Legends.types'; +import { tokens, typographyStyles } from '@fluentui/react-theme'; +import { HighContrastSelector } from '../../utilities/index'; + +/** + * @internal + */ +export const legendClassNames: SlotClassNames = { + root: 'fui-legend__root', + legend: 'fui-legend__legend', + rect: 'fui-legend__rect', + shape: 'fui-legend__shape', + triangle: 'fui-legend__triangle', + text: 'fui-legend__text', + hoverChange: 'fui-legend__hoverChange', + resizableArea: 'fui-legend__resizableArea', +}; + +const useStyles = makeStyles({ + root: { + whiteSpace: 'nowrap', + width: '100%', + alignItems: 'center', + ...shorthands.margin('-8px 0 0 -8px'), + }, + legend: { + // setting display to flex does not work + // display: 'flex', + alignItems: 'center', + justifyContent: 'left', + cursor: 'pointer', + ...shorthands.border('none'), + ...shorthands.padding(tokens.spacingHorizontalS), + textTransform: 'capitalize', + }, + rect: { + [HighContrastSelector]: { + content: 'var(--rect-content-high-contrast)', + opacity: 'var(--rect-opacity-high-contrast)', + }, + width: '12px', + border: '1px solid', + marginRight: tokens.spacingHorizontalS, + }, + shape: { + marginRight: tokens.spacingHorizontalS, + }, + // TO DO Add props when these styles are used in the component + triangle: { + width: '0', + height: '0', + ...shorthands.borderLeft('6px solid transparent'), + ...shorthands.borderRight('6px solid transparent'), + ...shorthands.borderTop('10.4px solid'), + marginRight: tokens.spacingHorizontalS, + }, + // TO DO Add props when these styles are used in the component + text: { + ...typographyStyles.caption1, + color: tokens.colorNeutralForeground1, + }, + // TO DO Add props when these styles are used in the component + hoverChange: { + width: '12px', + height: '12px', + marginRight: tokens.spacingHorizontalS, + ...shorthands.border('1px solid'), + }, + resizableArea: { + position: 'relative', + textAlign: 'left', + transform: 'translate(-50%, 0)', + top: 'auto', + left: '50%', + minWidth: '200px', + maxWidth: '800px', + '::after': { + ...shorthands.padding('1px 4px 1px'), + ...shorthands.borderTop('-2px'), + ...shorthands.borderLeft('-2px'), + }, + }, +}); + +export const useLegendStyles_unstable = (props: LegendsProps): LegendsStyles => { + // ToDo - width, barHeight is non enumerable. Need to be used inline. + const baseStyles = useStyles(); + + return { + root: mergeClasses(legendClassNames.root, baseStyles.root, props.className?.root), + legend: mergeClasses(legendClassNames.legend, baseStyles.legend, props.className?.legend), + rect: mergeClasses(legendClassNames.rect, baseStyles.rect, props.className?.rect), + shape: mergeClasses(legendClassNames.shape, baseStyles.shape, props.className?.shape), + triangle: mergeClasses(legendClassNames.triangle, baseStyles.triangle, props.className?.triangle), + text: mergeClasses(legendClassNames.text, baseStyles.text, props.className?.text), + hoverChange: mergeClasses(legendClassNames.hoverChange, baseStyles.hoverChange, props.className?.hoverChange), + resizableArea: mergeClasses( + legendClassNames.resizableArea, + baseStyles.resizableArea, + props.className?.resizableArea, + ), + }; +}; diff --git a/packages/charts/react-charts-preview/library/src/components/LineChart/LineChart.test.tsx b/packages/charts/react-charts-preview/library/src/components/LineChart/LineChart.test.tsx new file mode 100644 index 0000000000000..c01f0a53b035a --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/LineChart/LineChart.test.tsx @@ -0,0 +1,342 @@ +jest.mock('react-dom'); +import * as React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { LineChartPoints, LineChartProps, LineChart } from './index'; +import { CustomizedCalloutData } from '../../index'; +import toJson from 'enzyme-to-json'; +import { act } from 'react-dom/test-utils'; + +// Wrapper of the LineChart to be tested. +let wrapper: ReactWrapper | undefined; +const originalRAF = window.requestAnimationFrame; + +function sharedAfterEach() { + if (wrapper) { + wrapper.unmount(); + wrapper = undefined; + } + + // Do this after unmounting the wrapper to make sure if any timers cleaned up on unmount are + // cleaned up in fake timers world + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((global.setTimeout as any).mock) { + jest.useRealTimers(); + } + jest.useRealTimers(); + window.requestAnimationFrame = originalRAF; +} + +const points: LineChartPoints[] = [ + { + legend: 'metaData1', + data: [ + { x: 20, y: 50 }, + { x: 40, y: 80 }, + ], + color: 'red', + }, +]; +export const chartPoints = { + chartTitle: 'LineChart', + lineChartData: points, +}; + +export const emptyChartPoints = { + chartTitle: 'EmptyLineChart', + lineChartData: [], +}; + +describe('LineChart snapShot testing', () => { + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + wrapper = undefined; + } + + // Do this after unmounting the wrapper to make sure if any timers cleaned up on unmount are + // cleaned up in fake timers world + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((global.setTimeout as any).mock) { + jest.useRealTimers(); + } + }); + // @FIXME: this tests is failing with jest 29.7.0 + it.skip('renders LineChart correctly', async () => { + await act(async () => { + wrapper = mount(); + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + }); + const tree = toJson(wrapper!, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + }); + + // @FIXME: this tests is failing with jest 29.7.0 + it.skip('renders hideLegend correctly', async () => { + await act(async () => { + wrapper = mount(); + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + }); + const tree = toJson(wrapper!, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + }); + + // @FIXME: this tests is failing with jest 29.7.0 + it.skip('renders hideTooltip correctly', async () => { + await act(async () => { + wrapper = mount(); + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + }); + const tree = toJson(wrapper!, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + }); + + // @FIXME: this tests is failing with jest 29.7.0 + it.skip('renders enabledLegendsWrapLines correctly', async () => { + await act(async () => { + wrapper = mount(); + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + }); + const tree = toJson(wrapper!, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + }); + + // @FIXME: this tests is failing with jest 29.7.0 + it.skip('renders showXAxisLablesTooltip correctly', async () => { + await act(async () => { + wrapper = mount(); + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + }); + if (wrapper) { + const tree = toJson(wrapper, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + } + }); + + // FIXME - non deterministic snapshots causing master pipeline breaks + it.skip('renders wrapXAxisLables correctly', async () => { + const mockGetComputedTextLength = jest.fn().mockReturnValue(100); + + // Replace the original method with the mock implementation + Object.defineProperty( + Object.getPrototypeOf(document.createElementNS('http://www.w3.org/2000/svg', 'tspan')), + 'getComputedTextLength', + { + value: mockGetComputedTextLength, + }, + ); + + await act(async () => { + wrapper = mount(); + await new Promise(resolve => setTimeout(resolve)); + wrapper!.update(); + }); + const tree = toJson(wrapper!, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + }); + + // @FIXME: this tests is failing with jest 29.7.0 + it.skip('renders yAxisTickFormat correctly', async () => { + await act(async () => { + wrapper = mount(); + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + }); + const tree = toJson(wrapper!, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + }); + + // @FIXME: this tests is failing with jest 29.7.0 + it.skip('Should render with default colors when line color is not provided', async () => { + const lineColor = points[0].color; + delete points[0].color; + await act(async () => { + wrapper = mount(); + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + }); + const tree = toJson(wrapper!, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + + points[0].color = lineColor; + }); +}); + +describe('LineChart - basic props', () => { + afterEach(sharedAfterEach); + + it('Should not mount legend when hideLegend true ', () => { + act(() => { + wrapper = mount(); + wrapper.update(); + }); + const hideLegendDOM = wrapper!.getDOMNode().querySelectorAll('[class^="legendContainer"]'); + expect(hideLegendDOM.length).toBe(0); + }); + + it('Should mount legend when hideLegend false ', () => { + act(() => { + wrapper = mount(); + wrapper.update(); + }); + const hideLegendDOM = wrapper!.getDOMNode().querySelectorAll('[class^="legendContainer"]'); + expect(hideLegendDOM).toBeDefined(); + }); + + it('Should mount callout when hideTootip false ', () => { + act(() => { + wrapper = mount(); + wrapper.update(); + }); + const hideLegendDOM = wrapper!.getDOMNode().querySelectorAll('[class^="ms-Layer"]'); + expect(hideLegendDOM).toBeDefined(); + }); + + it('Should not mount callout when hideTootip true ', () => { + act(() => { + wrapper = mount(); + }); + const hideLegendDOM = wrapper!.getDOMNode().querySelectorAll('[class^="ms-Layer"]'); + expect(hideLegendDOM.length).toBe(0); + }); +}); + +describe('Render calling with respective to props', () => { + it('No prop changes', () => { + const props = { + data: chartPoints, + height: 300, + width: 600, + }; + act(() => { + const component = mount(); + expect(component).toMatchSnapshot(); + component.setProps({ ...props }); + expect(component).toMatchSnapshot(); + }); + }); + + it('prop changes', async () => { + const props = { + data: chartPoints, + height: 300, + width: 600, + hideLegend: true, + }; + const component = mount(); + expect(component.props().hideTooltip).toBe(undefined); + await act(async () => { + component.setProps({ ...props, hideTooltip: true }); + }); + expect(component.props().hideTooltip).toBe(true); + component.unmount(); + }); +}); + +describe('LineChart - mouse events', () => { + let root: HTMLDivElement | null; + + beforeEach(() => { + root = document.createElement('div'); + document.body.appendChild(root); + }); + + afterEach(() => { + sharedAfterEach(); + + if (root) { + document.body.removeChild(root); + root = null; + } + }); + + // @FIXME: this tests is failing with jest 29.7.0 + it.skip('Should render callout correctly on mouseover', () => { + act(() => { + // document.getElementbyId() returns null if component is not attached to DOM + wrapper = mount(, { attachTo: root }); + wrapper.find('line[id^="lineID"]').at(0).simulate('mouseover'); + }); + // Direct DOM changes like toggling visibility attr of verticalLine dont seem to update enzyme wrapper here + // but these changes are visible in wrapper.html() + const tree = toJson(wrapper!, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + }); + + it('Should render callout correctly on mousemove', () => { + act(() => { + wrapper = mount(, { attachTo: root }); + wrapper.find('path[id^="circle"]').at(0).simulate('mousemove'); + const html1 = wrapper.html(); + wrapper.find('path[id^="circle"]').at(1).simulate('mousemove'); + const html2 = wrapper.html(); + expect(html1).not.toBe(html2); + }); + }); + + // @FIXME: this tests is failing with jest 29.7.0 + it.skip('Should render customized callout on mouseover', () => { + act(() => { + wrapper = mount( + + props ? ( +
+
{JSON.stringify(props, null, 2)}
+
+ ) : null + } + />, + { attachTo: root }, + ); + wrapper.find('line[id^="lineID"]').at(0).simulate('mouseover'); + }); + const tree = toJson(wrapper!, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + }); + + // @FIXME: this tests is failing with jest 29.7.0 + it.skip('Should render customized callout per stack on mouseover', () => { + act(() => { + wrapper = mount( + + props ? ( +
+
{JSON.stringify(props, null, 2)}
+
+ ) : null + } + />, + { attachTo: root }, + ); + wrapper.find('line[id^="lineID"]').at(0).simulate('mouseover'); + }); + const tree = toJson(wrapper!, { mode: 'deep' }); + expect(tree).toMatchSnapshot(); + }); +}); + +describe('Render empty chart aria label div when chart is empty', () => { + it('No empty chart aria label div rendered', () => { + act(() => { + wrapper = mount(); + }); + const renderedDOM = wrapper!.findWhere(node => node.prop('aria-label') === 'Graph has no data to display'); + expect(renderedDOM!.length).toBe(0); + }); + + it('Empty chart aria label div rendered', () => { + act(() => { + wrapper = mount(); + }); + const renderedDOM = wrapper!.findWhere(node => node.prop('aria-label') === 'Graph has no data to display'); + expect(renderedDOM!.length).toBe(1); + }); +}); diff --git a/packages/charts/react-charts-preview/library/src/components/LineChart/LineChart.tsx b/packages/charts/react-charts-preview/library/src/components/LineChart/LineChart.tsx new file mode 100644 index 0000000000000..4aff4a03a7231 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/LineChart/LineChart.tsx @@ -0,0 +1,1340 @@ +import * as React from 'react'; +import { LineChartProps } from './LineChart.types'; +import { useLineChartStyles_unstable } from './useLineChartStyles.styles'; +import { Axis as D3Axis } from 'd3-axis'; +import { select as d3Select, pointer } from 'd3-selection'; +import { bisector } from 'd3-array'; +import { Legend, Legends } from '../Legends/index'; +import { line as d3Line, curveLinear as d3curveLinear } from 'd3-shape'; +import { useId } from '@fluentui/react-utilities'; +import { find } from '../../utilities/index'; +import { + AccessibilityProps, + CartesianChart, + ChildProps, + LineChartPoints, + CustomizedCalloutData, + Margins, + RefArrayData, + ColorFillBarsProps, + LineChartGap, + LineChartDataPoint, +} from '../../index'; +import { EventsAnnotation } from './eventAnnotation/EventAnnotation'; +import { tokens } from '@fluentui/react-theme'; +import { + calloutData, + ChartTypes, + getXAxisType, + XAxisTypes, + tooltipOfXAxislabels, + Points, + pointTypes, + getMinMaxOfYAxis, + getTypeOfAxis, + getNextColor, + getColorFromToken, + useRtl, + formatDate, +} from '../../utilities/index'; + +type NumericAxis = D3Axis; +enum PointSize { + hoverSize = 11, + invisibleSize = 1, +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const bisect = bisector((d: any) => d.x).left; + +const DEFAULT_LINE_STROKE_SIZE = 4; +// The given shape of a icon must be 2.5 times bigger than line width (known as stroke width) +const PATH_MULTIPLY_SIZE = 2.5; + +/** + * + * @param x units from origin + * @param y units from origin + * @param w is the legnth of the each side of a shape + * @param index index to get the shape path + */ +const _getPointPath = (x: number, y: number, w: number, index: number): string => { + const allPointPaths = [ + // circle path + `M${x - w / 2} ${y} + A${w / 2} ${w / 2} 0 1 0 ${x + w / 2} ${y} + M${x - w / 2} ${y} + A ${w / 2} ${w / 2} 0 1 1 ${x + w / 2} ${y} + `, + //square + `M${x - w / 2} ${y - w / 2} + L${x + w / 2} ${y - w / 2} + L${x + w / 2} ${y + w / 2} + L${x - w / 2} ${y + w / 2} + Z`, + //triangle + `M${x - w / 2} ${y - 0.2886 * w} + H ${x + w / 2} + L${x} ${y + 0.5774 * w} Z`, + //diamond + `M${x} ${y - w / 2} + L${x + w / 2} ${y} + L${x} ${y + w / 2} + L${x - w / 2} ${y} + Z`, + //pyramid + `M${x} ${y - 0.5774 * w} + L${x + w / 2} ${y + 0.2886 * w} + L${x - w / 2} ${y + 0.2886 * w} Z`, + //hexagon + `M${x - 0.5 * w} ${y - 0.866 * w} + L${x + 0.5 * w} ${y - 0.866 * w} + L${x + w} ${y} + L${x + 0.5 * w} ${y + 0.866 * w} + L${x - 0.5 * w} ${y + 0.866 * w} + L${x - w} ${y} + Z`, + //pentagon + `M${x} ${y - 0.851 * w} + L${x + 0.6884 * w} ${y - 0.2633 * w} + L${x + 0.5001 * w} ${y + 0.6884 * w} + L${x - 0.5001 * w} ${y + 0.6884 * w} + L${x - 0.6884 * w} ${y - 0.2633 * w} + Z`, + //octagon + `M${x - 0.5001 * w} ${y - 1.207 * w} + L${x + 0.5001 * w} ${y - 1.207 * w} + L${x + 1.207 * w} ${y - 0.5001 * w} + L${x + 1.207 * w} ${y + 0.5001 * w} + L${x + 0.5001 * w} ${y + 1.207 * w} + L${x - 0.5001 * w} ${y + 1.207 * w} + L${x - 1.207 * w} ${y + 0.5001 * w} + L${x - 1.207 * w} ${y - 0.5001 * w} + Z`, + ]; + return allPointPaths[index]; +}; + +type LineChartDataWithIndex = LineChartPoints & { index: number }; + +// Create a LineChart variant which uses these default styles and this styled subcomponent. +/** + * Linechart component + * {@docCategory LineChart} + */ +export const LineChart: React.FunctionComponent = React.forwardRef( + (props, forwardedRef) => { + let _points: LineChartDataWithIndex[] = _injectIndexPropertyInLineChartData(props.data.lineChartData); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let _calloutPoints: any[] = calloutData(_points) || []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let _xAxisScale: any = ''; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let _yAxisScale: any = ''; + let _circleId: string = useId('circle'); + let _lineId: string = useId('lineID'); + let _borderId: string = useId('borderID'); + let _verticalLine: string = useId('verticalLine'); + let _colorFillBarPatternId: string = useId('colorFillBarPattern'); + let _uniqueCallOutID: string | null = ''; + let _refArray: RefArrayData[] = []; + let margins: Margins; + let eventLabelHeight: number = 36; + let lines: JSX.Element[]; + let _renderedColorFillBars: JSX.Element[]; + const _colorFillBars = React.useRef([]); + let _tooltipId: string = useId('LineChartTooltipId_'); + let _rectId: string = useId('containerRectLD'); + let _staticHighlightCircle: string = useId('staticHighlightCircle'); + let _firstRenderOptimization = true; + let _emptyChartId: string = useId('_LineChart_empty'); + const _colorFillBarId = useId('_colorFillBarId'); + const _isRTL: boolean = useRtl(); + let xAxisCalloutAccessibilityData: AccessibilityProps = {}; + + props.eventAnnotationProps && + props.eventAnnotationProps.labelHeight && + (eventLabelHeight = props.eventAnnotationProps.labelHeight); + + const [hoverXValue, setHoverXValue] = React.useState(''); + const [activeLegend, setActiveLegend] = React.useState(''); + const [YValueHover, setYValueHover] = React.useState<[]>([]); + const [selectedLegend, setSelectedLegend] = React.useState(''); + const [selectedLegendPoints, setSelectedLegendPoints] = React.useState([]); + const [selectedColorBarLegend, setSelectedColorBarLegend] = React.useState([]); + const [isSelectedLegend, setIsSelectedLegend] = React.useState(false); + const [activePoint, setActivePoint] = React.useState(''); + const [nearestCircleToHighlight, setNearestCircleToHighlight] = React.useState(null); + const [dataPointCalloutProps, setDataPointCalloutProps] = React.useState(); + const [stackCalloutProps, setStackCalloutProps] = React.useState(); + const [clickPosition, setClickPosition] = React.useState({ x: 0, y: 0 }); + const [isPopoverOpen, setPopoverOpen] = React.useState(false); + + const pointsRef = React.useRef([]); + const calloutPointsRef = React.useRef([]); + React.useEffect(() => { + /** note that height and width are not used to resize or set as dimesions of the chart, + * fitParentContainer is responisble for setting the height and width or resizing of the svg/chart + */ + + if (_points !== _injectIndexPropertyInLineChartData(props.data.lineChartData) || props.data !== _points) { + pointsRef.current = _injectIndexPropertyInLineChartData(props.data.lineChartData); + calloutPointsRef.current = calloutData(pointsRef.current); + } + }, [props.height, props.width, props.data]); + + function _injectIndexPropertyInLineChartData(lineChartData?: LineChartPoints[]): LineChartDataWithIndex[] | [] { + const { allowMultipleShapesForPoints = false } = props; + return lineChartData + ? lineChartData.map((item: LineChartPoints, index: number) => { + let color: string; + if (typeof item.color === 'undefined') { + color = getNextColor(index, 0); + } else { + color = getColorFromToken(item.color); + } + return { + ...item, + index: allowMultipleShapesForPoints ? index : -1, + color, + }; + }) + : []; + } + + function updatePosition(newX: number, newY: number) { + const threshold = 1; // Set a threshold for movement + const { x, y } = clickPosition; + // Calculate the distance moved + const distance = Math.sqrt(Math.pow(newX - x, 2) + Math.pow(newY - y, 2)); + // Update the position only if the distance moved is greater than the threshold + if (distance > threshold) { + setClickPosition({ x: newX, y: newY }); + setPopoverOpen(true); + } + } + + function _getCustomizedCallout() { + return props.onRenderCalloutPerStack + ? props.onRenderCalloutPerStack(stackCalloutProps) + : props.onRenderCalloutPerDataPoint + ? props.onRenderCalloutPerDataPoint(dataPointCalloutProps) + : null; + } + + function _getMargins(_margins: Margins) { + margins = _margins; + } + + function _initializeLineChartData( + xScale: NumericAxis, + yScale: NumericAxis, + containerHeight: number, + containerWidth: number, + xElement: SVGElement | null, + ) { + _xAxisScale = xScale; + _yAxisScale = yScale; + _renderedColorFillBars = props.colorFillBars ? _createColorFillBars(containerHeight) : []; + lines = _createLines(xElement!, containerHeight!); + } + + function _handleSingleLegendSelectionAction(lineChartItem: LineChartDataWithIndex | ColorFillBarsProps) { + if (selectedLegend === lineChartItem.legend) { + setSelectedLegend(''); + _handleLegendClick(lineChartItem, null); + } else { + setSelectedLegend(lineChartItem.legend); + _handleLegendClick(lineChartItem, lineChartItem.legend); + } + } + + function _onHoverCardHide() { + setSelectedLegendPoints([]); + setSelectedColorBarLegend([]); + setIsSelectedLegend(false); + } + + function _handleLegendClick( + lineChartItem: LineChartDataWithIndex | ColorFillBarsProps, + selectedLegend: string | null | string[], + ): void { + if (lineChartItem.onLegendClick) { + lineChartItem.onLegendClick(selectedLegend); + } + } + + function _createLegends(data: LineChartDataWithIndex[]): JSX.Element { + const { legendProps, allowMultipleShapesForPoints = false } = props; + const isLegendMultiSelectEnabled = !!(legendProps && !!legendProps.canSelectMultipleLegends); + const legendDataItems = data.map((point: LineChartDataWithIndex) => { + const color: string = point.color!; + // mapping data to the format Legends component needs + const legend: Legend = { + title: point.legend!, + color, + action: () => { + if (isLegendMultiSelectEnabled) { + _handleMultipleLineLegendSelectionAction(point); + } else { + _handleSingleLegendSelectionAction(point); + } + }, + onMouseOutAction: () => { + setActiveLegend(''); + }, + hoverAction: () => { + _handleChartMouseLeave(); + setActiveLegend(point.legend); + }, + ...(point.legendShape && { + shape: point.legendShape, + }), + ...(allowMultipleShapesForPoints && { + shape: Points[point.index % Object.keys(pointTypes).length] as Legend['shape'], + }), + }; + return legend; + }); + + const colorFillBarsLegendDataItems = props.colorFillBars + ? props.colorFillBars.map((colorFillBar: ColorFillBarsProps, index: number) => { + const title = colorFillBar.legend; + const color = getColorFromToken(colorFillBar.color); + const legend: Legend = { + title, + color, + action: () => { + if (isLegendMultiSelectEnabled) { + _handleMultipleColorFillBarLegendSelectionAction(colorFillBar); + } else { + _handleSingleLegendSelectionAction(colorFillBar); + } + }, + onMouseOutAction: () => { + setActiveLegend(''); + }, + hoverAction: () => { + _handleChartMouseLeave(); + setActiveLegend(title); + }, + opacity: _getColorFillBarOpacity(colorFillBar), + stripePattern: colorFillBar.applyPattern, + }; + return legend; + }) + : []; + + return ( + + ); + } + + function _getBoxWidthOfShape(pointId: string, pointIndex: number, isLastPoint: boolean) { + const { allowMultipleShapesForPoints = false, strokeWidth = DEFAULT_LINE_STROKE_SIZE } = props; + if (allowMultipleShapesForPoints) { + if (activePoint === pointId) { + return PointSize.hoverSize; + } else if (pointIndex === 1 || isLastPoint) { + return strokeWidth * PATH_MULTIPLY_SIZE; + } else { + return PointSize.invisibleSize; + } + } else { + if (activePoint === pointId) { + return PointSize.hoverSize; + } else { + return PointSize.invisibleSize; + } + } + } + + function _getPath( + xPos: number, + yPos: number, + pointId: string, + pointIndex: number, + isLastPoint: boolean, + pointOftheLine: number, + ): string { + const { allowMultipleShapesForPoints = false } = props; + let w = _getBoxWidthOfShape(pointId, pointIndex, isLastPoint); + const index: number = allowMultipleShapesForPoints ? pointOftheLine % Object.keys(pointTypes).length : 0; + const widthRatio = pointTypes[index].widthRatio; + w = widthRatio > 1 ? w / widthRatio : w; + + return _getPointPath(xPos, yPos, w, index); + } + function _getPointFill(lineColor: string, pointId: string, pointIndex: number, isLastPoint: boolean) { + const { allowMultipleShapesForPoints = false } = props; + if (allowMultipleShapesForPoints) { + if (pointIndex === 1 || isLastPoint) { + if (activePoint === pointId) { + return tokens.colorNeutralBackground1; + } else { + return lineColor; + } + } else { + if (activePoint === pointId) { + return tokens.colorNeutralBackground1; + } else { + return lineColor; + } + } + } else { + if (activePoint === pointId) { + return tokens.colorNeutralBackground1; + } else { + return lineColor; + } + } + } + + function _createLines(xElement: SVGElement, containerHeight: number): JSX.Element[] { + const lines: JSX.Element[] = []; + if (isSelectedLegend) { + _points = selectedLegendPoints; + } else { + _points = _injectIndexPropertyInLineChartData(props.data.lineChartData); + } + for (let i = _points.length - 1; i >= 0; i--) { + const linesForLine: JSX.Element[] = []; + const bordersForLine: JSX.Element[] = []; + const pointsForLine: JSX.Element[] = []; + + const legendVal: string = _points[i].legend; + const lineColor: string = _points[i].color!; + const verticaLineHeight = containerHeight - margins.bottom! + 6; + if (_points[i].data.length === 1) { + // eslint-disable-next-line @typescript-eslint/no-shadow + const { x: x1, y: y1, xAxisCalloutData, xAxisCalloutAccessibilityData } = _points[i].data[0]; + const circleId = `${_circleId}_${i}`; + const isLegendSelected: boolean = _legendHighlighted(legendVal) || _noLegendHighlighted() || isSelectedLegend; + pointsForLine.push( + ) => + _handleHover( + x1, + y1, + verticaLineHeight, + xAxisCalloutData, + circleId, + xAxisCalloutAccessibilityData, + event, + ) + } + onMouseMove={(event: React.MouseEvent) => + _handleHover( + x1, + y1, + verticaLineHeight, + xAxisCalloutData, + circleId, + xAxisCalloutAccessibilityData, + event, + ) + } + onMouseOut={_handleMouseOut} + strokeWidth={activePoint === circleId ? DEFAULT_LINE_STROKE_SIZE : 0} + stroke={activePoint === circleId ? lineColor : ''} + role="img" + aria-label={_getAriaLabel(i, 0)} + data-is-focusable={isLegendSelected} + ref={(e: SVGCircleElement | null) => { + _refCallback(e!, circleId); + }} + onFocus={() => _handleFocus(circleId, x1, xAxisCalloutData, circleId, xAxisCalloutAccessibilityData)} + onBlur={_handleMouseOut} + {..._getClickHandler(_points[i].data[0].onDataPointClick)} + />, + ); + } + + let gapIndex = 0; + const gaps = _points[i].gaps?.sort((a, b) => a.startIndex - b.startIndex) ?? []; + + // Use path rendering technique for larger datasets to optimize performance. + if (props.optimizeLargeData && _points[i].data.length > 1) { + const line = d3Line() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .x((d: any) => _xAxisScale(d[0])) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .y((d: any) => _yAxisScale(d[1])) + .curve(d3curveLinear); + + const lineId = `${_lineId}_${i}`; + const borderId = `${_borderId}_${i}`; + const strokeWidth = _points[i].lineOptions?.strokeWidth || props.strokeWidth || DEFAULT_LINE_STROKE_SIZE; + + const isLegendSelected: boolean = _legendHighlighted(legendVal) || _noLegendHighlighted() || isSelectedLegend; + + const lineData: [number, number][] = []; + for (let k = 0; k < _points[i].data.length; k++) { + lineData.push([ + _points[i].data[k].x instanceof Date + ? (_points[i].data[k].x as Date).getTime() + : (_points[i].data[k].x as number), + _points[i].data[k].y, + ]); + } + + if (isLegendSelected) { + const lineBorderWidth = _points[i].lineOptions?.lineBorderWidth + ? Number.parseFloat(_points[i].lineOptions!.lineBorderWidth!.toString()) + : 0; + if (lineBorderWidth > 0) { + bordersForLine.push( + , + ); + } + + linesForLine.push( + _onMouseOverLargeDataset.bind(i, verticaLineHeight, event)} + onMouseOver={event => _onMouseOverLargeDataset.bind(i, verticaLineHeight, event)} + onMouseOut={_handleMouseOut} + {..._getClickHandler(_points[i].onLineClick)} + opacity={1} + tabIndex={_points[i].legend !== '' ? 0 : undefined} + />, + ); + } else { + linesForLine.push( + , + ); + } + + pointsForLine.push( + _onMouseOverLargeDataset.bind(i, verticaLineHeight, event)} + onMouseOver={event => _onMouseOverLargeDataset.bind(i, verticaLineHeight, event)} + onMouseOut={_handleMouseOut} + />, + ); + } else if (!props.optimizeLargeData) { + for (let j = 1; j < _points[i].data.length; j++) { + const gapResult = _checkInGap(j, gaps, gapIndex); + const isInGap = gapResult.isInGap; + gapIndex = gapResult.gapIndex; + + const lineId = `${_lineId}_${i}_${j}`; + const borderId = `${_borderId}_${i}_${j}`; + const circleId = `${_circleId}_${i}_${j}`; + const { x: x1, y: y1, xAxisCalloutData, xAxisCalloutAccessibilityData } = _points[i].data[j - 1]; + const { x: x2, y: y2 } = _points[i].data[j]; + let path = _getPath(_xAxisScale(x1), _yAxisScale(y1), circleId, j, false, _points[i].index); + const strokeWidth = _points[i].lineOptions?.strokeWidth || props.strokeWidth || DEFAULT_LINE_STROKE_SIZE; + + const isLegendSelected: boolean = + _legendHighlighted(legendVal) || _noLegendHighlighted() || isSelectedLegend; + + const currentPointHidden = _points[i].hideNonActiveDots && activePoint !== circleId; + pointsForLine.push( + ) => + _handleHover( + x1, + y1, + verticaLineHeight, + xAxisCalloutData, + circleId, + xAxisCalloutAccessibilityData, + event, + ) + } + onMouseMove={(event: React.MouseEvent) => + _handleHover( + x1, + y1, + verticaLineHeight, + xAxisCalloutData, + circleId, + xAxisCalloutAccessibilityData, + event, + ) + } + onMouseOut={_handleMouseOut} + onFocus={() => _handleFocus(lineId, x1, xAxisCalloutData, circleId, xAxisCalloutAccessibilityData)} + onBlur={_handleMouseOut} + {..._getClickHandler(_points[i].data[j - 1].onDataPointClick)} + opacity={isLegendSelected && !currentPointHidden ? 1 : 0.01} + fill={_getPointFill(lineColor, circleId, j, false)} + stroke={lineColor} + strokeWidth={strokeWidth} + role="img" + aria-label={_getAriaLabel(i, j - 1)} + tabIndex={_points[i].legend !== '' ? 0 : undefined} + />, + ); + if (j + 1 === _points[i].data.length) { + // If this is last point of the line segment. + const lastCircleId = `${circleId}${j}L`; + const hiddenHoverCircleId = `${circleId}${j}D`; + const lastPointHidden = _points[i].hideNonActiveDots && activePoint !== lastCircleId; + path = _getPath(_xAxisScale(x2), _yAxisScale(y2), lastCircleId, j, true, _points[i].index); + const { + xAxisCalloutData: lastCirlceXCallout, + xAxisCalloutAccessibilityData: lastCirlceXCalloutAccessibilityData, + } = _points[i].data[j]; + pointsForLine.push( + + ) => + _handleHover( + x2, + y2, + verticaLineHeight, + lastCirlceXCallout, + lastCircleId, + lastCirlceXCalloutAccessibilityData, + event, + ) + } + onMouseMove={(event: React.MouseEvent) => + _handleHover( + x2, + y2, + verticaLineHeight, + lastCirlceXCallout, + lastCircleId, + lastCirlceXCalloutAccessibilityData, + event, + ) + } + onMouseOut={_handleMouseOut} + onFocus={() => + _handleFocus(lineId, x2, lastCirlceXCallout, lastCircleId, lastCirlceXCalloutAccessibilityData) + } + onBlur={_handleMouseOut} + {..._getClickHandler(_points[i].data[j].onDataPointClick)} + opacity={isLegendSelected && !lastPointHidden ? 1 : 0.01} + fill={_getPointFill(lineColor, lastCircleId, j, true)} + stroke={lineColor} + strokeWidth={strokeWidth} + role="img" + aria-label={_getAriaLabel(i, j)} + tabIndex={_points[i].legend !== '' ? 0 : undefined} + /> + {/* Dummy circle acting as magnetic latch for last callout point */} + ) => + _handleHover( + x2, + y2, + verticaLineHeight, + lastCirlceXCallout, + lastCircleId, + lastCirlceXCalloutAccessibilityData, + event, + ) + } + onMouseMove={(event: React.MouseEvent) => + _handleHover( + x2, + y2, + verticaLineHeight, + lastCirlceXCallout, + lastCircleId, + lastCirlceXCalloutAccessibilityData, + event, + ) + } + onMouseOut={_handleMouseOut} + strokeWidth={0} + focusable={false} + onBlur={_handleMouseOut} + /> + , + ); + /* eslint-enable react/jsx-no-bind */ + } + + if (isLegendSelected) { + // don't draw line if it is in a gap + if (!isInGap) { + const lineBorderWidth = _points[i].lineOptions?.lineBorderWidth + ? Number.parseFloat(_points[i].lineOptions!.lineBorderWidth!.toString()) + : 0; + if (lineBorderWidth > 0) { + bordersForLine.push( + , + ); + } + + linesForLine.push( + { + _refCallback(e!, lineId); + }} + onMouseOver={(event: React.MouseEvent) => + _handleHover( + x1, + y1, + verticaLineHeight, + xAxisCalloutData, + circleId, + xAxisCalloutAccessibilityData, + event, + ) + } + onMouseMove={(event: React.MouseEvent) => + _handleHover( + x1, + y1, + verticaLineHeight, + xAxisCalloutData, + circleId, + xAxisCalloutAccessibilityData, + event, + ) + } + onMouseOut={_handleMouseOut} + stroke={lineColor} + strokeLinecap={_points[i].lineOptions?.strokeLinecap ?? 'round'} + strokeDasharray={_points[i].lineOptions?.strokeDasharray} + strokeDashoffset={_points[i].lineOptions?.strokeDashoffset} + opacity={1} + {..._getClickHandler(_points[i].onLineClick)} + />, + ); + } + } else { + if (!isInGap) { + linesForLine.push( + , + ); + } + } + } + } + + lines.push( + + {bordersForLine} + {linesForLine} + {pointsForLine} + , + ); + } + const classes = useLineChartStyles_unstable(props); + // Removing un wanted tooltip div from DOM, when prop not provided. + if (!props.showXAxisLablesTooltip) { + try { + document.getElementById(_tooltipId) && document.getElementById(_tooltipId)!.remove(); + // eslint-disable-next-line no-empty + } catch (e) {} + } + // Used to display tooltip at x axis labels. + if (!props.wrapXAxisLables && props.showXAxisLablesTooltip) { + const xAxisElement = d3Select(xElement).call(_xAxisScale); + try { + document.getElementById(_tooltipId) && document.getElementById(_tooltipId)!.remove(); + // eslint-disable-next-line no-empty + } catch (e) {} + const tooltipProps = { + tooltipCls: classes.tooltip!, + id: _tooltipId, + xAxis: xAxisElement, + }; + xAxisElement && tooltipOfXAxislabels(tooltipProps); + } + return lines; + } + + function _createColorFillBars(containerHeight: number) { + const colorFillBars: JSX.Element[] = []; + if (isSelectedLegend) { + _colorFillBars.current = selectedColorBarLegend; + } else { + _colorFillBars.current = props.colorFillBars!; + } + + const yMinMaxValues = getMinMaxOfYAxis(_points, ChartTypes.LineChart); + const FILL_Y_PADDING = 3; + for (let i = 0; i < _colorFillBars.current.length; i++) { + const colorFillBar = _colorFillBars.current[i]; + const colorFillBarId = `${_colorFillBarId}-${i}`; + const color = getColorFromToken(colorFillBar.color); + + if (colorFillBar.applyPattern) { + // Using a pattern element because CSS was unable to render diagonal stripes for rect elements + colorFillBars.push(_getStripePattern(color, i)); + } + + for (let j = 0; j < colorFillBar.data.length; j++) { + const startX = colorFillBar.data[j].startX; + const endX = colorFillBar.data[j].endX; + const opacity = + _legendHighlighted(colorFillBar.legend) || _noLegendHighlighted() || isSelectedLegend + ? _getColorFillBarOpacity(colorFillBar) + : 0.1; + colorFillBars.push( + , + ); + } + } + return colorFillBars; + } + + function _getStripePattern(color: string, id: number) { + // This describes a tile pattern that resembles diagonal stripes + // For more information: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d + const stripePath = 'M-4,4 l8,-8 M0,16 l16,-16 M12,20 l8,-8'; + return ( + + + + ); + } + + function _checkInGap(pointIndex: number, gaps: LineChartGap[], currentGapIndex: number) { + let gapIndex = currentGapIndex; + let isInGap = false; + + while (gapIndex < gaps.length && pointIndex > gaps[gapIndex].endIndex) { + gapIndex++; + } + + if (gapIndex < gaps.length && pointIndex > gaps[gapIndex].startIndex && pointIndex <= gaps[gapIndex].endIndex) { + isInGap = true; + } + return { isInGap, gapIndex }; + } + + function _refCallback(element: SVGGElement, legendTitle: string): void { + _refArray.push({ index: legendTitle, refElement: element }); + } + + const _onMouseOverLargeDataset = ( + linenumber: number, + lineHeight: number, + mouseEvent: React.MouseEvent, + ) => { + mouseEvent.persist(); + const { data } = props; + const { lineChartData } = data; + + // This will get the value of the X when mouse is on the chart + const xOffset = _xAxisScale.invert(pointer(mouseEvent)[0], document.getElementById(_rectId)!); + const i = bisect(lineChartData![linenumber].data, xOffset); + const d0 = lineChartData![linenumber].data[i - 1] as LineChartDataPoint; + const d1 = lineChartData![linenumber].data[i] as LineChartDataPoint; + let axisType: XAxisTypes | null = null; + let xPointToHighlight: string | Date | number = 0; + let index: null | number = null; + if (d0 === undefined && d1 !== undefined) { + xPointToHighlight = d1.x; + index = i; + } else if (d0 !== undefined && d1 === undefined) { + xPointToHighlight = d0.x; + index = i - 1; + } else { + axisType = getTypeOfAxis(lineChartData![linenumber].data[0].x, true) as XAxisTypes; + let x0; + let point0; + let point1; + switch (axisType) { + case XAxisTypes.DateAxis: + x0 = new Date(xOffset).getTime(); + point0 = (d0.x as Date).getTime(); + point1 = (d1.x as Date).getTime(); + xPointToHighlight = Math.abs(x0 - point0) > Math.abs(x0 - point1) ? d1.x : d0.x; + index = Math.abs(x0 - point0) > Math.abs(x0 - point1) ? i : i - 1; + break; + case XAxisTypes.NumericAxis: + x0 = xOffset as number; + point0 = d0.x as number; + point1 = d1.x as number; + xPointToHighlight = Math.abs(x0 - point0) > Math.abs(x0 - point1) ? d1.x : d0.x; + index = Math.abs(x0 - point0) > Math.abs(x0 - point1) ? i : i - 1; + break; + default: + break; + } + } + + const { xAxisCalloutData } = lineChartData![linenumber].data[index as number]; + const formattedDate = + xPointToHighlight instanceof Date ? formatDate(xPointToHighlight, props.useUTC) : xPointToHighlight; + const modifiedXVal = xPointToHighlight instanceof Date ? xPointToHighlight.getTime() : xPointToHighlight; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const found: any = find(_calloutPoints, (element: { x: string | number }) => { + return element.x === modifiedXVal; + }); + const pointToHighlight: LineChartDataPoint = lineChartData![linenumber].data[index!]; + const pointToHighlightUpdated = + nearestCircleToHighlight === null || + (nearestCircleToHighlight !== null && + pointToHighlight !== null && + (nearestCircleToHighlight.x !== pointToHighlight.x || nearestCircleToHighlight.y !== pointToHighlight.y)); + // if no points need to be called out then don't show vertical line and callout card + if (found && pointToHighlightUpdated) { + _uniqueCallOutID = `#${_staticHighlightCircle}_${linenumber}`; + + d3Select(`#${_staticHighlightCircle}_${linenumber}`) + .attr('cx', `${_xAxisScale(pointToHighlight.x)}`) + .attr('cy', `${_yAxisScale(pointToHighlight.y)}`) + .attr('visibility', 'visibility'); + + d3Select(`#${_verticalLine}`) + .attr('transform', () => `translate(${_xAxisScale(pointToHighlight.x)}, ${_yAxisScale(pointToHighlight.y)})`) + .attr('visibility', 'visibility') + .attr('y2', `${lineHeight - _yAxisScale(pointToHighlight.y)}`); + + setNearestCircleToHighlight(pointToHighlight); + updatePosition(mouseEvent.clientX, mouseEvent.clientY); + setStackCalloutProps(found!); + setYValueHover(found.values); + setDataPointCalloutProps(found!); + xAxisCalloutData ? setHoverXValue(xAxisCalloutData) : setHoverXValue(formattedDate); + setActivePoint(''); + } + + if (!found) { + setPopoverOpen(false); + setNearestCircleToHighlight(pointToHighlight); + setActivePoint(''); + } + }; + + function _handleFocus( + lineId: string, + x: number | Date, + + xAxisCalloutData: string | undefined, + circleId: string, + xAxisCalloutAccessibilityData?: AccessibilityProps, + ) { + _uniqueCallOutID = circleId; + const formattedData = x instanceof Date ? formatDate(x, props.useUTC) : x; + const xVal = x instanceof Date ? x.getTime() : x; + const found = find(_calloutPoints, (element: { x: string | number }) => element.x === xVal); + // if no points need to be called out then don't show vertical line and callout card + + if (found) { + d3Select(`#${_verticalLine}`) + .attr('transform', () => `translate(${_xAxisScale(x)}, 0)`) + .attr('visibility', 'visibility'); + _refArray.forEach((obj: RefArrayData) => { + if (obj.index === lineId) { + setPopoverOpen(true); + xAxisCalloutData ? setHoverXValue(xAxisCalloutData) : setHoverXValue('' + formattedData); + setYValueHover(found.values); + setStackCalloutProps(found!); + setDataPointCalloutProps(found!); + setActivePoint(circleId); + } + }); + } else { + setActivePoint(circleId); + } + } + + function _handleHover( + x: number | Date, + y: number | Date, + lineHeight: number, + xAxisCalloutData: string | undefined, + circleId: string, + xAxisCalloutAccessibilityData: AccessibilityProps | undefined, + mouseEvent: React.MouseEvent, + ) { + mouseEvent?.persist(); + const formattedData = x instanceof Date ? formatDate(x, props.useUTC) : x; + const xVal = x instanceof Date ? x.getTime() : x; + const found = find(_calloutPoints, (element: { x: string | number }) => element.x === xVal); + // if no points need to be called out then don't show vertical line and callout card + + if (found) { + d3Select(`#${_verticalLine}`) + .attr('transform', () => `translate(${_xAxisScale(x)}, ${_yAxisScale(y)})`) + .attr('visibility', 'visibility') + .attr('y2', `${lineHeight - _yAxisScale(y)}`); + + if (_uniqueCallOutID !== circleId) { + _uniqueCallOutID = circleId; + updatePosition(mouseEvent.clientX, mouseEvent.clientY); + xAxisCalloutData ? setHoverXValue(xAxisCalloutData) : setHoverXValue('' + formattedData); + setYValueHover(found.values); + setStackCalloutProps(found!); + setDataPointCalloutProps(found!); + setActivePoint(circleId); + setNearestCircleToHighlight(null); + } + } else { + setActivePoint(circleId); + setNearestCircleToHighlight(null); + } + } + + /** + * Screen readers announce an element as clickable if the onClick attribute is set. + * This function sets the attribute only when a click event handler is provided.*/ + + function _getClickHandler(func?: () => void): { onClick?: () => void } { + if (func) { + return { + onClick: func, + }; + } + + return {}; + } + + function _handleMouseOut() { + d3Select(`#${_verticalLine}`).attr('visibility', 'hidden'); + } + + function _handleChartMouseLeave() { + _uniqueCallOutID = null; + setActivePoint(''); + if (isPopoverOpen) { + setPopoverOpen(false); + } + } + + function _handleMultipleLineLegendSelectionAction(selectedLine: LineChartDataWithIndex) { + const selectedLineIndex = selectedLegendPoints.reduce((acc, line, index) => { + if (acc > -1 || line.legend !== selectedLine.legend) { + return acc; + } else { + return index; + } + }, -1); + + let selectedLines: LineChartDataWithIndex[]; + if (selectedLineIndex === -1) { + selectedLines = [...selectedLegendPoints, selectedLine]; + } else { + selectedLines = selectedLegendPoints + .slice(0, selectedLineIndex) + .concat(selectedLegendPoints.slice(selectedLineIndex + 1)); + } + + const areAllLineLegendsSelected = props.data && selectedLines.length === props.data.lineChartData!.length; + + if ( + areAllLineLegendsSelected && + ((props.colorFillBars && props.colorFillBars.length === selectedColorBarLegend.length) || !props.colorFillBars) + ) { + // Clear all legends if all legends including color fill bar legends are selected + // Or clear all legends if all legends are selected and there are no color fill bars + _clearMultipleLegendSelections(); + } else if (!selectedLines.length && !selectedColorBarLegend.length) { + // Clear all legends if no legends including color fill bar legends are selected + _clearMultipleLegendSelections(); + } else { + // Otherwise, set state when one or more legends are selected, including color fill bar legends + setSelectedLegendPoints(selectedLines); + setIsSelectedLegend(true); + } + + const selectedLegendTitlesToPass = selectedLines.map((line: LineChartDataWithIndex) => line.legend); + _handleLegendClick(selectedLine, selectedLegendTitlesToPass); + } + + function _handleMultipleColorFillBarLegendSelectionAction(selectedColorFillBar: ColorFillBarsProps) { + const selectedColorFillBarIndex = selectedColorBarLegend.reduce((acc, colorFillBar, index) => { + if (acc > -1 || colorFillBar.legend !== selectedColorFillBar.legend) { + return acc; + } else { + return index; + } + }, -1); + + let selectedColorFillBars: ColorFillBarsProps[]; + if (selectedColorFillBarIndex === -1) { + selectedColorFillBars = [...selectedColorBarLegend, selectedColorFillBar]; + } else { + selectedColorFillBars = selectedColorBarLegend + .slice(0, selectedColorFillBarIndex) + .concat(selectedColorBarLegend.slice(selectedColorFillBarIndex + 1)); + } + + const areAllColorFillBarLegendsSelected = + selectedColorFillBars.length === (props.colorFillBars && props.colorFillBars!.length); + + if ( + areAllColorFillBarLegendsSelected && + ((props.data && props.data.lineChartData!.length === selectedLegendPoints.length) || !props.data) + ) { + // Clear all legends if all legends, including line legends, are selected + // Or clear all legends if all legends are selected and there is no line data + _clearMultipleLegendSelections(); + } else if (!selectedColorFillBars.length && !selectedLegendPoints.length) { + // Clear all legends if no legends are selected, including line legends + _clearMultipleLegendSelections(); + } else { + // set state when one or more legends are selected, including line legends + setSelectedColorBarLegend(selectedColorFillBars); + setIsSelectedLegend(true); + } + + const selectedLegendTitlesToPass = selectedColorFillBars.map( + (colorFillBar: ColorFillBarsProps) => colorFillBar.legend, + ); + _handleLegendClick(selectedColorFillBar, selectedLegendTitlesToPass); + } + + function _clearMultipleLegendSelections() { + setSelectedColorBarLegend([]); + setSelectedLegendPoints([]); + setIsSelectedLegend(false); + } + + /** + * This function checks if the given legend is highlighted or not. + * A legend can be highlighted in 2 ways: + * 1. selection: if the user clicks on it + * 2. hovering: if there is no selected legend and the user hovers over it*/ + + function _legendHighlighted(legend: string) { + return selectedLegend === legend || (selectedLegend === '' && activeLegend === legend); + } + + /** + * This function checks if none of the legends is selected or hovered.*/ + + function _noLegendHighlighted() { + return selectedLegend === '' && activeLegend === ''; + } + + function _getColorFillBarOpacity(colorFillBar: ColorFillBarsProps) { + return colorFillBar.applyPattern ? 1 : 0.4; + } + + function _getAriaLabel(lineIndex: number, pointIndex: number): string { + const line = _points[lineIndex]; + const point = line.data[pointIndex]; + const formattedDate = point.x instanceof Date ? formatDate(point.x, props.useUTC) : point.x; + const xValue = point.xAxisCalloutData || formattedDate; + const legend = line.legend; + const yValue = point.yAxisCalloutData || point.y; + return point.callOutAccessibilityData?.ariaLabel || `${xValue}. ${legend}, ${yValue}.`; + } + + function _isChartEmpty(): boolean { + return !( + props.data && + props.data.lineChartData && + props.data.lineChartData.length > 0 && + props.data.lineChartData.filter((item: LineChartPoints) => item.data.length).length > 0 + ); + } + + const { legendProps, tickValues, tickFormat, eventAnnotationProps } = props; + _points = _injectIndexPropertyInLineChartData(props.data.lineChartData); + + const isXAxisDateType = getXAxisType(_points); + let points = _points; + if (legendProps && !!legendProps.canSelectMultipleLegends) { + points = selectedLegendPoints.length >= 1 ? selectedLegendPoints : _points; + _calloutPoints = calloutData(points); + } + + let legendBars = null; + // reduce computation cost by only creating legendBars + // if when hideLegend is false. + // NOTE: they are rendered only when hideLegend is false in CartesianChart. + if (!props.hideLegend) { + legendBars = _createLegends(_points!); // ToDo: Memoize legends to improve performance. + } + const calloutProps = { + YValueHover: YValueHover, + hoverXValue: hoverXValue, + descriptionMessage: + props.getCalloutDescriptionMessage && stackCalloutProps + ? props.getCalloutDescriptionMessage(stackCalloutProps) + : undefined, + 'data-is-focusable': true, + xAxisCalloutAccessibilityData: xAxisCalloutAccessibilityData, + ...props.calloutProps, + clickPosition: clickPosition, + isPopoverOpen: isPopoverOpen, + isCalloutForStack: true, + culture: props.culture ?? 'en-us', + isCartesian: true, + customCallout: { + customizedCallout: _getCustomizedCallout() !== null ? _getCustomizedCallout()! : undefined, + customCalloutProps: props.customProps ? props.customProps(dataPointCalloutProps!) : undefined, + }, + }; + const tickParams = { + tickValues, + tickFormat, + }; + + return !_isChartEmpty() ? ( + { + _xAxisScale = props.xScale!; + _yAxisScale = props.yScale!; + return ( + <> + + + {props.optimizeLargeData ? ( + + ) : ( + <> + )} + + {_renderedColorFillBars} + {lines} + + {eventAnnotationProps && ( + + )} + + + ); + }} + /> + ) : ( +
+ ); + }, +); +LineChart.displayName = 'LineChart'; diff --git a/packages/charts/react-charts-preview/library/src/components/LineChart/LineChart.types.ts b/packages/charts/react-charts-preview/library/src/components/LineChart/LineChart.types.ts new file mode 100644 index 0000000000000..1c0820389f48f --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/LineChart/LineChart.types.ts @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { RenderFunction } from '../../utilities/index'; +import { + ChartProps, + LineChartPoints, + Margins, + Basestate, + RefArrayData, + CustomizedCalloutData, +} from '../../types/index'; +import { EventAnnotation } from '../../types/EventAnnotation'; +import { + CartesianChartProps, + CartesianChartStyleProps, + CartesianChartStyles, + ChildProps, +} from '../CommonComponents/index'; + +export type { ChildProps, LineChartPoints, Margins, Basestate, RefArrayData }; + +/** + * Line Chart properties + * {@docCategory LineChart} + */ +export interface LineChartProps extends CartesianChartProps { + /** + * Data to render in the chart. + */ + data: ChartProps; + + /** + * Call to provide customized styling that will layer on top of the variant rules. + */ + styles?: LineChartStyles; + + /** + * Show event annotation + */ + eventAnnotationProps?: EventsAnnotationProps; + + /** + * Define a custom callout renderer for a data point + */ + onRenderCalloutPerDataPoint?: RenderFunction; + + /** + * Define a custom callout renderer for a stack; default is to render per data point + */ + onRenderCalloutPerStack?: RenderFunction; + + /** + * Callback for getting callout description message + */ + getCalloutDescriptionMessage?: (calloutDataProps: CustomizedCalloutData) => string | undefined; + + /* + * Color fill bars for the chart, + */ + colorFillBars?: ColorFillBarsProps[]; + + /** + * if this is set to true, then for each line there will be a unique shape assigned to the point, + * there are total 8 shapes which are as follow circle, square, triangele, diamond, pyramid, + * hexagon, pentagon and octagon, which will get assigned as respectively, if there are more + * than 8 lines in the line chart then it will again start from cicle to octagon. + * setting this flag to true will also change the behavior of the points, like for a + * line, last point shape and first point shape will be visible all the times, and all + * other points will get enlarge only when hovered over them + * if set to false default shape will be circle, with the existing behavior + * @default false + */ + allowMultipleShapesForPoints?: boolean; + + /* + * Optimize line chart rendering for large data set. If this prop is enabled, line chart + * can easily render over 10K datapoints with multiple lines smoothly. + * This rendering mechanism does not support gaps in lines. + */ + optimizeLargeData?: boolean; + + /** + * The prop used to define the culture to localized the numbers + */ + culture?: string; + + /** + * @default false + * The prop used to enable the perf optimization + */ + enablePerfOptimization?: boolean; +} + +/** + * {@docCategory LineChart} + */ +export interface EventsAnnotationProps { + events: EventAnnotation[]; + strokeColor?: string; + labelColor?: string; + labelHeight?: number; + labelWidth?: number; + mergedLabel: (count: number) => string; +} + +/** + * Line Chart styles + * {@docCategory LineChart} + */ +export interface LineChartStyles extends CartesianChartStyles {} + +/** + * Line Chart style properties + * {@docCategory LineChart} + */ +export interface LineChartStyleProps extends CartesianChartStyleProps {} + +/** + * {@docCategory LineChart} + */ +export interface ColorFillBarsProps { + legend: string; + color: string; + data: ColorFillBarData[]; + applyPattern?: boolean; + onLegendClick?: (selectedLegend: string | string[] | null) => void | undefined; +} + +/** + * {@docCategory LineChart} + */ +export interface ColorFillBarData { + startX: number | Date; + endX: number | Date; +} diff --git a/packages/charts/react-charts-preview/library/src/components/LineChart/LineChartRTL.test.tsx b/packages/charts/react-charts-preview/library/src/components/LineChart/LineChartRTL.test.tsx new file mode 100644 index 0000000000000..9517f6531cb3e --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/LineChart/LineChartRTL.test.tsx @@ -0,0 +1,701 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import * as React from 'react'; +import { FluentProvider } from '@fluentui/react-provider'; +import { LineChartPoints, LineChart } from './index'; +import '@testing-library/jest-dom'; + +import { + getByClass, + getById, + testWithWait, + testWithoutWait, + isTimezoneSet, + isTestEnv, +} from '../../utilities/TestUtility.test'; +import { axe, toHaveNoViolations } from 'jest-axe'; +const { Timezone } = require('../../../scripts/constants'); + +expect.extend(toHaveNoViolations); + +const originalRAF = window.requestAnimationFrame; + +/* function updateChartWidthAndHeight() { + jest.useFakeTimers(); + Object.defineProperty(window, 'requestAnimationFrame', { + writable: true, + value: (callback: FrameRequestCallback) => callback(0), + }); + window.HTMLElement.prototype.getBoundingClientRect = () => + ({ + bottom: 44, + height: 50, + left: 10, + right: 35.67, + top: 20, + width: 650, + } as DOMRect); +} */ + +function sharedAfterEach() { + jest.useRealTimers(); + window.requestAnimationFrame = originalRAF; +} + +const basicPoints: LineChartPoints[] = [ + { + legend: 'metaData1', + data: [ + { x: 20, y: 50 }, + { x: 40, y: 80 }, + ], + color: 'red', + }, + { + legend: 'metaData2', + data: [ + { x: 30, y: 60 }, + { x: 50, y: 90 }, + ], + color: 'green', + }, + { + legend: 'metaData3', + data: [ + { x: 70, y: 30 }, + { x: 40, y: 80 }, + ], + color: 'yellow', + }, +]; + +const basicChartPoints = { + chartTitle: 'LineChart', + lineChartData: basicPoints, +}; + +const datePoints: LineChartPoints[] = [ + { + data: [ + { x: new Date('2020-01-01T00:00:00.000Z'), y: 30 }, + { x: new Date('2020-02-01T00:00:00.000Z'), y: 50 }, + { x: new Date('2020-03-01T00:00:00.000Z'), y: 30 }, + { x: new Date('2020-04-01T00:00:00.000Z'), y: 50 }, + { x: new Date('2020-05-01T00:00:00.000Z'), y: 30 }, + { x: new Date('2020-06-01T00:00:00.000Z'), y: 50 }, + ], + legend: 'First', + lineOptions: { + lineBorderWidth: '4', + }, + }, +]; + +const dateChartPoints = { + chartTitle: 'LineChart', + lineChartData: datePoints, +}; + +const colorFillBarData = [ + { + legend: 'Time range 1', + color: 'blue', + data: [ + { + startX: new Date('2020-01-01T00:00:00.000Z'), + endX: new Date('2020-02-01T00:00:00.000Z'), + }, + ], + }, + { + legend: 'Time range 2', + color: 'red', + data: [ + { + startX: new Date('2018-04-01T00:00:00.000Z'), + endX: new Date('2020-05-01T00:00:00.000Z'), + }, + ], + applyPattern: true, + }, +]; + +const pointsWithGaps: LineChartPoints[] = [ + { + legend: 'Normal Data', + hideNonActiveDots: true, + lineOptions: { + lineBorderWidth: '4', + }, + gaps: [ + { + startIndex: 3, + endIndex: 4, + }, + { + startIndex: 6, + endIndex: 7, + }, + ], + data: [ + { + x: new Date('2020-03-03T00:00:00.000Z'), + y: 216000, + }, + { + x: new Date('2020-03-03T10:30:00.000Z'), + y: 218123, + hideCallout: true, + }, + // gap here + { + x: new Date('2020-03-03T11:00:00.000Z'), + y: 219000, + hideCallout: true, + }, + { + x: new Date('2020-03-04T00:00:00.000Z'), + y: 248000, + hideCallout: true, + }, + // gap here + { + x: new Date('2020-03-05T00:00:00.000Z'), + y: 252000, + hideCallout: true, + }, + { + x: new Date('2020-03-06T00:00:00.000Z'), + y: 274000, + }, + { + x: new Date('2020-03-07T00:00:00.000Z'), + y: 260000, + hideCallout: true, + }, + // gap here + { + x: new Date('2020-03-08T00:00:00.000Z'), + y: 300000, + hideCallout: true, + }, + { + x: new Date('2020-03-08T12:00:00.000Z'), + y: 218000, + }, + { + x: new Date('2020-03-09T00:00:00.000Z'), + y: 218000, + }, + { + x: new Date('2020-03-10T00:00:00.000Z'), + y: 269000, + }, + ], + color: '#0000FF', + }, +]; + +const secondaryYScalePoints = [{ yMaxValue: 50000, yMinValue: 10000 }]; + +const chartPointsWithGaps = { + chartTitle: 'LineChart', + lineChartData: pointsWithGaps, +}; + +const tickValues = [ + new Date('2020-03-03T00:00:00.000Z'), + new Date('2020-03-04T00:00:00.000Z'), + new Date('2020-03-05T00:00:00.000Z'), + new Date('2020-03-06T00:00:00.000Z'), + new Date('2020-03-07T00:00:00.000Z'), + new Date('2020-03-08T00:00:00.000Z'), + new Date('2020-03-09T00:00:00.000Z'), +]; + +describe('Line chart rendering', () => { + afterEach(sharedAfterEach); + + testWithoutWait( + 'Should render the Line chart with numeric x-axis data', + LineChart, + { data: basicChartPoints }, + container => { + // Assert + expect(container).toMatchSnapshot(); + }, + ); + + testWithoutWait( + 'Should render the Line chart with date x-axis data', + LineChart, + { data: dateChartPoints }, + container => { + // Assert + expect(container).toMatchSnapshot(); + }, + undefined, + undefined, + !(isTimezoneSet(Timezone.UTC) && isTestEnv()), + ); + + const testCases = [ + ['when tick Values is given', { data: dateChartPoints, tickValues, tickFormat: '%m/%d' }], + ['when tick Values not given and tick format is given', { data: dateChartPoints, tickFormat: '%m/%d' }], + ['when tick Values is given and tick format not given', { data: dateChartPoints, tickValues }], + ['when tick Values given and tick format is %m/%d/%y', { data: dateChartPoints, tickFormat: '%m/%d/%y' }], + ['when tick Values given and tick format is %d', { data: dateChartPoints, tickValues, tickFormat: '%d' }], + ['when tick Values given and tick format is %m', { data: dateChartPoints, tickValues, tickFormat: '%m' }], + ['when tick Values given and tick format is %m/%y', { data: dateChartPoints, tickValues, tickFormat: '%m/%y' }], + ]; + testCases.forEach(([testcase, props]) => { + testWithWait( + `Should render the Line chart with date x-axis data ${testcase}`, + LineChart, + props, + container => { + // Assert + expect(container).toMatchSnapshot(); + }, + undefined, + undefined, + !(isTimezoneSet(Timezone.UTC) && isTestEnv()), + ); + }); + + testWithoutWait( + 'Should render the Line chart with points in multiple shapes', + LineChart, + { data: basicChartPoints, allowMultipleShapesForPoints: true }, + container => { + // Assert + expect(container).toMatchSnapshot(); + }, + ); + + testWithoutWait( + 'Should render the Line Chart with secondary Y axis', + LineChart, + { data: basicChartPoints, secondaryYScaleOptions: secondaryYScalePoints }, + container => { + // Assert + expect(getById(container, /yAxisGElementSecondarychart_/i)).toBeDefined(); + }, + ); +}); + +const simplePoints = { + chartTitle: 'Line Chart', + lineChartData: [ + { + legend: 'From_Legacy_to_O365', + data: [ + { + x: new Date('2020-03-03T00:00:00.000Z'), + y: 297, + }, + { + x: new Date('2020-03-04T00:00:00.000Z'), + y: 284, + }, + { + x: new Date('2020-03-05T00:00:00.000Z'), + y: 282, + }, + { + x: new Date('2020-03-06T00:00:00.000Z'), + y: 294, + }, + { + x: new Date('2020-03-07T00:00:00.000Z'), + y: 294, + }, + { + x: new Date('2020-03-08T00:00:00.000Z'), + y: 300, + }, + { + x: new Date('2020-03-09T00:00:00.000Z'), + y: 298, + }, + ], + color: 'blue', + lineOptions: { + lineBorderWidth: '4', + }, + }, + { + legend: 'All', + data: [ + { + x: new Date('2020-03-03T00:00:00.000Z'), + y: 292, + }, + { + x: new Date('2020-03-04T00:00:00.000Z'), + y: 287, + }, + { + x: new Date('2020-03-05T00:00:00.000Z'), + y: 287, + }, + { + x: new Date('2020-03-06T00:00:00.000Z'), + y: 292, + }, + { + x: new Date('2020-03-07T00:00:00.000Z'), + y: 287, + }, + { + x: new Date('2020-03-08T00:00:00.000Z'), + y: 297, + }, + { + x: new Date('2020-03-09T00:00:00.000Z'), + y: 292, + }, + ], + color: 'green', + lineOptions: { + lineBorderWidth: '4', + }, + }, + ], +}; + +const eventAnnotationProps = { + events: [ + { + event: 'event 1', + date: new Date('2020-03-04T00:00:00.000Z'), + onRenderCard: () =>
event 1 message
, + }, + { + event: 'event 2', + date: new Date('2020-03-04T00:00:00.000Z'), + onRenderCard: () =>
event 2 message
, + }, + { + event: 'event 3', + date: new Date('2020-03-04T00:00:00.000Z'), + onRenderCard: () =>
event 3 message
, + }, + { + event: 'event 4', + date: new Date('2020-03-06T00:00:00.000Z'), + onRenderCard: () =>
event 4 message
, + }, + { + event: 'event 5', + date: new Date('2020-03-08T00:00:00.000Z'), + onRenderCard: () =>
event 5 message
, + }, + ], + strokeColor: 'red', + labelColor: 'Yellow', + labelHeight: 18, + labelWidth: 2, + mergedLabel: (count: number) => `${count} events`, +}; + +describe('Line chart - Subcomponent line', () => { + testWithoutWait( + 'Should render the lines with the specified colors', + LineChart, + { data: basicChartPoints }, + container => { + const lines = getById(container, /lineID/i); + // Assert + expect(lines[0].getAttribute('stroke')).toEqual('yellow'); + expect(lines[1].getAttribute('stroke')).toEqual('green'); + expect(lines[2].getAttribute('stroke')).toEqual('red'); + }, + ); + + testWithoutWait( + 'Should render the line with the sepcified gaps', + LineChart, + { data: chartPointsWithGaps }, + container => { + const lines = getById(container, /lineID/i); + // Assert + expect(lines).toHaveLength(8); + }, + ); +}); + +describe('Line chart - Subcomponent legend', () => { + testWithoutWait( + 'Should highlight the corresponding Line on mouse over on legends', + LineChart, + { data: basicChartPoints }, + container => { + const legend = screen.queryByText('metaData1'); + expect(legend).toBeDefined(); + fireEvent.mouseOver(legend!); + // Assert + const lines = getById(container, /lineID/i); + expect(lines[0].getAttribute('opacity')).toEqual('0.1'); + expect(lines[1].getAttribute('opacity')).toEqual('0.1'); + expect(lines[2].getAttribute('opacity')).toEqual('1'); + expect(screen.queryByText('metaData2')).toHaveStyle('opacity: 0.67'); + }, + ); + + testWithoutWait( + 'Should reset the highlighted line on mouse leave on legends', + LineChart, + { data: basicChartPoints }, + container => { + const legend = screen.queryByText('metaData1'); + expect(legend).toBeDefined(); + fireEvent.mouseOver(legend!); + // Assert + const lines = getById(container, /lineID/i); + expect(lines[0].getAttribute('opacity')).toEqual('0.1'); + expect(lines[1].getAttribute('opacity')).toEqual('0.1'); + expect(lines[2].getAttribute('opacity')).toEqual('1'); + + fireEvent.mouseLeave(legend!); + expect(lines[0].getAttribute('opacity')).toEqual('1'); + expect(lines[1].getAttribute('opacity')).toEqual('1'); + expect(lines[2].getAttribute('opacity')).toEqual('1'); + }, + ); + + testWithoutWait( + 'Should select legend on single mouse click on legends', + LineChart, + { data: basicChartPoints, hideLegend: false }, + container => { + // Arrange + const legend = screen.queryByText('metaData1'); + expect(legend).toBeDefined(); + fireEvent.click(legend!); + // Assert + expect(getById(container, /line/i)[1]).toHaveAttribute('opacity', '0.1'); + const firstLegend = screen.queryByText('metaData1')?.closest('button'); + expect(firstLegend).toHaveAttribute('aria-selected', 'true'); + }, + ); + + testWithoutWait( + 'Should deselect legend on double mouse click on legends', + LineChart, + { data: basicChartPoints, hideLegend: false }, + container => { + const legend = screen.queryByText('metaData1'); + expect(legend).toBeDefined(); + //single click on first legend + fireEvent.click(legend!); + expect(getById(container, /line/i)[1]).toHaveAttribute('opacity', '0.1'); + const firstLegend = screen.queryByText('metaData1')?.closest('button'); + expect(firstLegend).toHaveAttribute('aria-selected', 'true'); + // double click on same first legend + fireEvent.click(legend!); + // Assert + expect(firstLegend).toHaveAttribute('aria-selected', 'false'); + }, + ); + + testWithoutWait( + 'Should select muultiple legends on single mouse click on different legends', + LineChart, + { + data: basicChartPoints, + hideLegend: false, + legendProps: { + allowFocusOnLegends: true, + canSelectMultipleLegends: true, + }, + }, + container => { + // Arrange + const legends = screen.getAllByText((content, element) => element!.tagName.toLowerCase() === 'button'); + expect(legends[0]).toBeDefined(); + fireEvent.click(legends[0]!); + expect(legends[1]).toBeDefined(); + fireEvent.click(legends[1]!); + const legendsAfterClick = screen.getAllByText((content, element) => element!.tagName.toLowerCase() === 'button'); + // Assert + expect(legendsAfterClick[0]).toHaveAttribute('aria-selected', 'true'); + expect(legendsAfterClick[1]).toHaveAttribute('aria-selected', 'true'); + expect(legendsAfterClick[2]).toHaveAttribute('aria-selected', 'false'); + }, + ); + + testWithoutWait( + 'Should select muultiple color fill bar legends', + LineChart, + { + data: basicChartPoints, + colorFillBars: colorFillBarData, + legendProps: { + allowFocusOnLegends: true, + canSelectMultipleLegends: true, + }, + }, + container => { + const legends = screen.getAllByText((content, element) => element!.tagName.toLowerCase() === 'button'); + expect(legends).toHaveLength(5); + expect(legends[3]).toBeDefined(); + //fireEvent.click(legends[3]!); - ToDo fix this test + //expect(legends[4]).toBeDefined(); + //fireEvent.click(legends[4]!); + const legendsAfterClick = screen.getAllByText((content, element) => element!.tagName.toLowerCase() === 'button'); + // Assert + expect(legendsAfterClick[0]).toHaveAttribute('aria-selected', 'false'); + expect(legendsAfterClick[1]).toHaveAttribute('aria-selected', 'false'); + expect(legendsAfterClick[2]).toHaveAttribute('aria-selected', 'false'); + // expect(legendsAfterClick[3]).toHaveAttribute('aria-selected', 'true'); - ToDo - Fix this test + // expect(legendsAfterClick[4]).toHaveAttribute('aria-selected', 'true'); - ToDo - Fix this test + }, + ); + + testWithWait( + 'Should highlight the data points and render the corresponding callout', + LineChart, + { data: basicChartPoints }, + container => { + // Arrange + const firstPointonLine = getById(container, /lineID/)[0]; + expect(firstPointonLine).toBeDefined(); + fireEvent.mouseOver(firstPointonLine); + // Assert + expect(getById(container, /toolTipcallout/i)).toHaveLength(0); + }, + ); +}); + +describe('Line chart - Subcomponent Time Range', () => { + testWithWait( + 'Should render time range with sepcified data', + LineChart, + { data: dateChartPoints, colorFillBars: colorFillBarData }, + container => { + // Assert + expect(getByClass(container, /rect/i).length > 0); + }, + ); + + testWithWait( + 'Should highlight corresponding time range on legend click', + LineChart, + { data: dateChartPoints, colorFillBars: colorFillBarData }, + container => { + const legend = screen.queryByText('Time range 1'); + expect(legend).toBeDefined(); + fireEvent.click(legend!); + const timeRangeLegend = screen.queryByText('Time range 1')?.closest('button'); + const lines = getById(container, /lineID/i); + const filledBars = screen.getAllByText((content, element) => element!.tagName.toLowerCase() === 'rect'); + // Assert + expect(timeRangeLegend).toHaveAttribute('aria-selected', 'true'); + expect(lines[0].getAttribute('opacity')).toEqual('0.1'); + expect(filledBars[0].getAttribute('fill-opacity')).toEqual('0.4'); + expect(filledBars[1].getAttribute('fill-opacity')).toEqual('0.1'); + }, + ); +}); + +describe('Line chart - Subcomponent xAxis Labels', () => { + testWithWait( + 'Should show the x-axis labels tooltip when hovered', + LineChart, + { data: dateChartPoints, showXAxisLablesTooltip: true }, + container => { + // Arrange + const xAxisLabels = getById(container, /showDots/i); + fireEvent.mouseOver(xAxisLabels[0]); + // Assert + expect(getById(container, /showDots/i)[0]!.textContent!).toEqual('Febr...'); + }, + ); +}); + +describe.skip('Line chart - Subcomponent Event', () => { + const mockGetComputedTextLength = jest.fn().mockReturnValue(100); + // Replace the original method with the mock implementation + Object.defineProperty( + Object.getPrototypeOf(document.createElementNS('http://www.w3.org/2000/svg', 'tspan')), + 'getComputedTextLength', + { + value: mockGetComputedTextLength, + }, + ); + testWithWait( + 'Should render events with defined data', + LineChart, + { data: simplePoints, eventAnnotationProps, tickValues, tickFormat: '%m/%d' }, + container => { + // Arrange + const event = screen.queryByText('3 events'); + // Assert + expect(event).toBeDefined(); + fireEvent.click(event!); + }, + ); +}); + +describe('Screen resolution', () => { + afterEach(sharedAfterEach); + + testWithWait( + 'Should remain unchanged on zoom in', + LineChart, + { data: basicChartPoints, rotateXAxisLables: true, width: 300, height: 300 }, + container => { + // Arrange + global.innerWidth = window.innerWidth / 2; + global.innerHeight = window.innerHeight / 2; + act(() => { + global.dispatchEvent(new Event('resize')); + }); + // Assert + expect(container).toMatchSnapshot(); + }, + ); + + testWithWait( + 'Should remain unchanged on zoom out', + LineChart, + { data: basicChartPoints, rotateXAxisLables: true, width: 300, height: 300 }, + container => { + // Arrange + global.innerWidth = window.innerWidth * 2; + global.innerHeight = window.innerHeight * 2; + act(() => { + global.dispatchEvent(new Event('resize')); + }); + // Assert + expect(container).toMatchSnapshot(); + }, + ); +}); + +describe('Theme and accessibility', () => { + afterEach(sharedAfterEach); + + test('Should reflect theme change', () => { + // Arrange + const { container } = render( + + + , + ); + // Assert + expect(container).toMatchSnapshot(); + }); +}); + +describe('Line chart - Accessibility', () => { + test('Should pass accessibility tests', async () => { + const { container } = render(); + let axeResults; + await act(async () => { + axeResults = await axe(container); + }); + expect(axeResults).toHaveNoViolations(); + }); +}); diff --git a/packages/charts/react-charts-preview/library/src/components/LineChart/__snapshots__/LineChart.test.tsx.snap b/packages/charts/react-charts-preview/library/src/components/LineChart/__snapshots__/LineChart.test.tsx.snap new file mode 100644 index 0000000000000..462b0a3388b02 --- /dev/null +++ b/packages/charts/react-charts-preview/library/src/components/LineChart/__snapshots__/LineChart.test.tsx.snap @@ -0,0 +1,4142 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LineChart - mouse events Should render callout correctly on mouseover 1`] = ` + +`; + +exports[`LineChart - mouse events Should render customized callout on mouseover 1`] = ` + +`; + +exports[`LineChart - mouse events Should render customized callout per stack on mouseover 1`] = ` + +`; + +exports[`LineChart snapShot testing Should render with default colors when line color is not provided 1`] = ` +