From ede4b31816884bcb9b27a8acd13d1e715012d6fa Mon Sep 17 00:00:00 2001 From: Kumar Kshitij Date: Wed, 17 Dec 2025 16:23:52 +0530 Subject: [PATCH 01/12] create basic polar chart --- .../react-charts/library/src/PolarChart.ts | 1 + .../components/PolarChart/PolarChart.test.tsx | 0 .../src/components/PolarChart/PolarChart.tsx | 367 ++++++++++++++++++ .../components/PolarChart/PolarChart.types.ts | 47 +++ .../src/components/PolarChart/index.ts | 2 + .../PolarChart/usePolarChartStyles.styles.ts | 32 ++ .../charts/react-charts/library/src/index.ts | 1 + .../library/src/types/DataPoint.ts | 30 ++ .../PolarChart/PolarChartDefault.stories.tsx | 126 ++++++ .../stories/src/PolarChart/index.stories.tsx | 15 + 10 files changed, 621 insertions(+) create mode 100644 packages/charts/react-charts/library/src/PolarChart.ts create mode 100644 packages/charts/react-charts/library/src/components/PolarChart/PolarChart.test.tsx create mode 100644 packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx create mode 100644 packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts create mode 100644 packages/charts/react-charts/library/src/components/PolarChart/index.ts create mode 100644 packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts create mode 100644 packages/charts/react-charts/stories/src/PolarChart/PolarChartDefault.stories.tsx create mode 100644 packages/charts/react-charts/stories/src/PolarChart/index.stories.tsx diff --git a/packages/charts/react-charts/library/src/PolarChart.ts b/packages/charts/react-charts/library/src/PolarChart.ts new file mode 100644 index 00000000000000..0bcf1067a40eb8 --- /dev/null +++ b/packages/charts/react-charts/library/src/PolarChart.ts @@ -0,0 +1 @@ +export * from './components/PolarChart/index'; diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.test.tsx b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.test.tsx new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx new file mode 100644 index 00000000000000..15734d2d922290 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx @@ -0,0 +1,367 @@ +'use client'; + +import * as React from 'react'; +import { PolarChartProps } from './PolarChart.types'; +import { usePolarChartStyles } from './usePolarChartStyles.styles'; +import { useImageExport } from '../../utilities/hooks'; +import { + scaleBand as d3ScaleBand, + scaleLinear as d3ScaleLinear, + scaleLog as d3ScaleLog, + scaleTime as d3ScaleTime, + scaleUtc as d3ScaleUtc, + ScaleBand, + ScaleContinuousNumeric, + ScaleTime, +} from 'd3-scale'; +import { extent as d3Extent } from 'd3-array'; +import { + pointRadial as d3PointRadial, + areaRadial as d3AreaRadial, + lineRadial as d3LineRadial, + curveLinearClosed as d3CurveLinearClosed, +} from 'd3-shape'; +import { AxisScaleType, DataPointV2 } from '../../types/DataPoint'; +import { tokens } from '@fluentui/react-theme'; +import { Legend, Legends } from '../Legends/index'; +import { getNextColor } from '../../utilities/colors'; + +const DEFAULT_LEGEND_HEIGHT = 32; + +export const PolarChart: React.FunctionComponent = React.forwardRef( + (props, forwardedRef) => { + const { chartContainerRef, legendsRef } = useImageExport(props.componentRef, props.hideLegend, false); + const legendContainerRef = React.useRef(null); + + const [containerWidth, setContainerWidth] = React.useState(200); + const [containerHeight, setContainerHeight] = React.useState(200); + const [legendContainerHeight, setLegendContainerHeight] = React.useState( + props.hideLegend ? 0 : DEFAULT_LEGEND_HEIGHT, + ); + React.useEffect(() => { + if (chartContainerRef.current) { + const { width, height } = chartContainerRef.current.getBoundingClientRect(); + setContainerWidth(width); + setContainerHeight(height); + } + }, []); + React.useEffect(() => { + if (props.hideLegend) { + setLegendContainerHeight(0); + } else if (legendContainerRef.current) { + const { height } = legendContainerRef.current.getBoundingClientRect(); + const { marginTop } = getComputedStyle(legendContainerRef.current); + setLegendContainerHeight(Math.max(height, DEFAULT_LEGEND_HEIGHT) + parseFloat(marginTop)); + } + }, [props.hideLegend, props.legendProps?.enabledWrapLines]); + + const margins = { + left: 0, + right: 0, + top: 0, + bottom: 0, + ...props.margins, + }; + + const svgWidth = props.width || containerWidth; + const svgHeight = (props.height || containerHeight) - legendContainerHeight; + const innerRadius = props.innerRadius || 0; + const outerRadius = + Math.min(svgWidth - (margins.left + margins.right), svgHeight - (margins.top + margins.bottom)) / 2; + + const xValues = props.data.flatMap(series => series.data.map(point => point.x)); + const xScaleType = getScaleType(xValues, { + scaleType: props.xScaleType, + supportsLog: true, + }); + const xDomain = getDomain(xScaleType, xValues); + const xScale = createScale(xScaleType, xDomain, [innerRadius, outerRadius], { innerPadding: 1 }); + + const yValues = props.data.flatMap(series => series.data.map(point => point.y)); + const yScaleType = getScaleType(yValues, { + scaleType: props.yScaleType, + supportsLog: true, + }); + const yDomain = getDomain(yScaleType, yValues); + const yScale = createScale(yScaleType, yScaleType === 'category' ? [...yDomain, ''] : yDomain, [0, 2 * Math.PI], { + innerPadding: 1, + }); + + const classes = usePolarChartStyles(props); + + const renderPolarGrid = () => { + return ( + <> + + {('ticks' in xScale ? xScale.ticks() : xScale.domain()).map((xTickValue, xTickIndex) => { + if (props.shape === 'polygon') { + let d = ''; + ('ticks' in yScale ? yScale.ticks() : yScale.domain().slice(0, -1)).forEach( + (yTickValue, yTickIndex) => { + const radialPoint = d3PointRadial(yScale(yTickValue as any)!, xScale(xTickValue as any)!); + d += (yTickIndex === 0 ? 'M' : 'L') + radialPoint.join(',') + ' '; + }, + ); + d += 'Z'; + + return ( + + ); + } + + return ( + + ); + })} + + + {('ticks' in yScale ? yScale.ticks() : yScale.domain().slice(0, -1)).map((yTickValue, yTickIndex) => { + const radialPoint1 = d3PointRadial(yScale(yTickValue as any)!, innerRadius); + const radialPoint2 = d3PointRadial(yScale(yTickValue as any)!, outerRadius); + + return ( + + ); + })} + + + ); + }; + + const renderPolarTicks = () => { + return ( + <> + + {('ticks' in xScale ? xScale.ticks() : xScale.domain()).map((xTickValue, xTickIndex) => { + return ( + + {`${xTickValue}`} + + ); + })} + + + {('ticks' in yScale ? yScale.ticks() : yScale.domain().slice(0, -1)).map((yTickValue, yTickIndex) => { + const radialPoint = d3PointRadial(yScale(yTickValue as any)!, outerRadius); + + return ( + + {`${yTickValue}`} + + ); + })} + + + ); + }; + + const renderRadialAreas = () => { + return ( + + {props.data + .filter(series => series.type === 'area') + .map((series, seriesIndex) => { + const radialArea = d3AreaRadial>() + .angle(d => yScale(d.y as any)!) + .innerRadius(innerRadius) + .outerRadius(d => xScale(d.x as any)!) + .curve(d3CurveLinearClosed); + + return ; + })} + + ); + }; + + const renderRadialLines = () => { + return ( + + {props.data + .filter(series => series.type === 'line') + .map((series, seriesIndex) => { + const radialLine = d3LineRadial>() + .angle(d => yScale(d.y as any)!) + .radius(d => xScale(d.x as any)!) + .curve(d3CurveLinearClosed); + + return ( + + ); + })} + + ); + }; + + const renderRadialPoints = () => { + return ( + + {props.data + .filter(series => series.type === 'scatter') + .map((series, seriesIndex) => { + return ( + + {series.data.map((point, pointIndex) => { + const radialPoint = d3PointRadial(yScale(point.y as any)!, xScale(point.x as any)!); + return ; + })} + + ); + })} + + ); + }; + + const renderLegends = () => { + if (props.hideLegend) { + return null; + } + + const legends: Legend[] = props.data.map((series, index) => { + const color: string = series.color || getNextColor(index, 0, false); + + return { + title: series.legend, + color, + hoverAction: () => { + // setHoveredLegend(series.legend); + }, + onMouseOutAction: () => { + // setHoveredLegend(''); + }, + }; + }); + + return ( +
+ +
+ ); + }; + + return ( +
+
+ + {renderPolarGrid()} + {renderRadialAreas()} + {renderRadialLines()} + {renderRadialPoints()} + {renderPolarTicks()} + +
+ {renderLegends()} +
+ ); + }, +); + +PolarChart.displayName = 'PolarChart'; + +const createScale = ( + scaleType: string, + domain: (string | number | Date)[], + range: number[], + opts: { + useUTC?: boolean; + niceBounds?: boolean; + innerPadding?: number; + } = {}, +) => { + if (scaleType === 'category') { + return d3ScaleBand() + .domain(domain as string[]) + .range(range) + .paddingInner(opts.innerPadding || 0); + } + + let scale: ScaleContinuousNumeric | ScaleTime; + switch (scaleType) { + case 'log': + scale = d3ScaleLog(); + break; + case 'date': + scale = opts.useUTC ? d3ScaleUtc() : d3ScaleTime(); + break; + default: + scale = d3ScaleLinear(); + } + + scale.domain(domain as (number | Date)[]); + scale.range(range); + if (opts.niceBounds) { + scale.nice(); + } + + return scale; +}; + +const getScaleType = ( + values: (string | number | Date)[], + opts: { + scaleType?: AxisScaleType; + supportsLog?: boolean; + } = {}, +) => { + let scaleType = 'category'; + if (typeof values[0] === 'number') { + if (opts.supportsLog && opts.scaleType === 'log') { + scaleType = 'log'; + } else { + scaleType = 'linear'; + } + } else if (values[0] instanceof Date) { + scaleType = 'date'; + } + return scaleType; +}; + +const getDomain = (scaleType: string, values: (string | number | Date)[], opts: {} = {}) => { + if (scaleType === 'category') { + return Array.from(new Set(values)); + } + + const extent = d3Extent(values as (number | Date)[]); + if (typeof extent[0] !== 'undefined' && typeof extent[1] !== 'undefined') { + return extent; + } + return []; +}; diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts new file mode 100644 index 00000000000000..b90547185b6c6a --- /dev/null +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts @@ -0,0 +1,47 @@ +import { + CartesianChartProps, + CartesianChartStyleProps, + CartesianChartStyles, +} from '../CommonComponents/CartesianChart.types'; +import { AreaSeries, LineSeries, ScatterSeries } from '../../types/DataPoint'; + +/** + * Polar Chart properties + * {@docCategory PolarChart} + */ +export interface PolarChartProps extends CartesianChartProps { + /** + * + */ + data: + | AreaSeries[] + | LineSeries[] + | ScatterSeries[]; + + /** + * + */ + chartTitle?: string; + + /** + * + */ + innerRadius?: number; + + /** + * @default 'circle' + */ + shape?: 'circle' | 'polygon'; +} + +/** + * Polar Chart style properties + * {@docCategory PolarChart} + */ +export interface PolarChartStyleProps extends CartesianChartStyleProps {} + +/** + * Polar Chart styles + * {@docCategory PolarChart} + */ +export interface PolarChartStyles extends CartesianChartStyles {} diff --git a/packages/charts/react-charts/library/src/components/PolarChart/index.ts b/packages/charts/react-charts/library/src/components/PolarChart/index.ts new file mode 100644 index 00000000000000..8e104d4467354d --- /dev/null +++ b/packages/charts/react-charts/library/src/components/PolarChart/index.ts @@ -0,0 +1,2 @@ +export * from './PolarChart'; +export * from './PolarChart.types'; diff --git a/packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts b/packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts new file mode 100644 index 00000000000000..06546eca5cf7be --- /dev/null +++ b/packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts @@ -0,0 +1,32 @@ +import { PolarChartStyles, PolarChartProps } from './PolarChart.types'; +import type { SlotClassNames } from '@fluentui/react-utilities'; + +/** + * @internal + */ +export const polarChartClassNames: SlotClassNames = { + root: '', + xAxis: '', + yAxis: '', + legendContainer: '', + hover: '', + descriptionMessage: '', + tooltip: '', + axisTitle: '', + chartTitle: '', + opacityChangeOnHover: '', + shapeStyles: '', + chartWrapper: '', + svgTooltip: '', + chart: '', + axisAnnotation: '', + plotContainer: '', + annotationLayer: '', +}; + +/** + * Apply styling to the PolarChart component + */ +export const usePolarChartStyles = (props: PolarChartProps): PolarChartStyles => { + return {}; +}; diff --git a/packages/charts/react-charts/library/src/index.ts b/packages/charts/react-charts/library/src/index.ts index 3d2a65cadd8582..3ce48ceebc7098 100644 --- a/packages/charts/react-charts/library/src/index.ts +++ b/packages/charts/react-charts/library/src/index.ts @@ -22,3 +22,4 @@ export * from './FunnelChart'; export * from './GanttChart'; export * from './ChartTable'; export * from './AnnotationOnlyChart'; +export * from './PolarChart'; diff --git a/packages/charts/react-charts/library/src/types/DataPoint.ts b/packages/charts/react-charts/library/src/types/DataPoint.ts index 61d0d9b6c9c822..90bb0fc6affa9e 100644 --- a/packages/charts/react-charts/library/src/types/DataPoint.ts +++ b/packages/charts/react-charts/library/src/types/DataPoint.ts @@ -1250,3 +1250,33 @@ export interface LineSeries void; } + +/** + * Represents a scatter series. + */ +export interface ScatterSeries extends DataSeries { + /** + * Type discriminator: always 'scatter' for this series. + */ + type: 'scatter'; + + /** + * Array of data points for the series. + */ + data: DataPointV2[]; +} + +/** + * Represents a area series. + */ +export interface AreaSeries extends DataSeries { + /** + * Type discriminator: always 'area' for this series. + */ + type: 'area'; + + /** + * Array of data points for the series. + */ + data: DataPointV2[]; +} diff --git a/packages/charts/react-charts/stories/src/PolarChart/PolarChartDefault.stories.tsx b/packages/charts/react-charts/stories/src/PolarChart/PolarChartDefault.stories.tsx new file mode 100644 index 00000000000000..ad6e1dd0fa3710 --- /dev/null +++ b/packages/charts/react-charts/stories/src/PolarChart/PolarChartDefault.stories.tsx @@ -0,0 +1,126 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { PolarChart, PolarChartProps } from '@fluentui/react-charts'; +import { Switch } from '@fluentui/react-components'; + +const data: PolarChartProps['data'] = [ + { + type: 'scatter', + legend: 'Mike', + color: 'red', + data: [ + { x: 120, y: 'Math' }, + { x: 98, y: 'Chinese' }, + { x: 86, y: 'English' }, + { x: 99, y: 'Geography' }, + { x: 85, y: 'Physics' }, + { x: 65, y: 'History' }, + ], + }, + { + type: 'scatter', + legend: 'Lily', + color: 'blue', + data: [ + { x: 110, y: 'Math' }, + { x: 130, y: 'Chinese' }, + { x: 130, y: 'English' }, + { x: 100, y: 'Geography' }, + { x: 90, y: 'Physics' }, + { x: 85, y: 'History' }, + ], + }, +]; + +export const PolarChartBasic = (): JSXElement => { + const [width, setWidth] = React.useState(600); + const [height, setHeight] = React.useState(350); + const [enableGradient, setEnableGradient] = React.useState(false); + const [roundedCorners, setRoundedCorners] = React.useState(false); + + React.useEffect(() => { + const style = document.createElement('style'); + const focusStylingCSS = ` + .containerDiv [contentEditable=true]:focus, + .containerDiv [tabindex]:focus, + .containerDiv area[href]:focus, + .containerDiv button:focus, + .containerDiv iframe:focus, + .containerDiv input:focus, + .containerDiv select:focus, + .containerDiv textarea:focus { + outline: -webkit-focus-ring-color auto 5px; + } + `; + style.appendChild(document.createTextNode(focusStylingCSS)); + document.head.appendChild(style); + return () => { + document.head.removeChild(style); + }; + }, []); + + return ( +
+
+
+ + setWidth(parseInt(e.target.value, 10))} + aria-valuetext={`Width: ${width}`} + /> + {width} +
+
+ + setHeight(parseInt(e.target.value, 10))} + aria-valuetext={`Height: ${height}`} + /> + {height} +
+
+
+
+ setEnableGradient(val.checked)} + label="Enable Gradient" + /> +
+
+ setRoundedCorners(val.checked)} + label="Rounded Corners" + /> +
+
+
+ +
+
+ ); +}; +PolarChartBasic.parameters = { + docs: { + description: {}, + }, +}; diff --git a/packages/charts/react-charts/stories/src/PolarChart/index.stories.tsx b/packages/charts/react-charts/stories/src/PolarChart/index.stories.tsx new file mode 100644 index 00000000000000..7a3385939a7f9a --- /dev/null +++ b/packages/charts/react-charts/stories/src/PolarChart/index.stories.tsx @@ -0,0 +1,15 @@ +import { PolarChart } from '@fluentui/react-charts'; + +export { PolarChartBasic } from './PolarChartDefault.stories'; + +export default { + title: 'Charts/PolarChart', + component: PolarChart, + parameters: { + docs: { + description: { + component: '', + }, + }, + }, +}; From 28dd818615da531200bea28d36998de60e335c1f Mon Sep 17 00:00:00 2001 From: Kumar Kshitij Date: Thu, 18 Dec 2025 17:12:25 +0530 Subject: [PATCH 02/12] make chart interactive and accessible --- .../src/components/PolarChart/PolarChart.tsx | 254 ++++++++++-------- .../components/PolarChart/PolarChart.utils.ts | 81 ++++++ .../PolarChart/PolarChartDefault.stories.tsx | 28 +- 3 files changed, 238 insertions(+), 125 deletions(-) create mode 100644 packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx index 15734d2d922290..8afcec7215aa80 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx @@ -4,29 +4,23 @@ import * as React from 'react'; import { PolarChartProps } from './PolarChart.types'; import { usePolarChartStyles } from './usePolarChartStyles.styles'; import { useImageExport } from '../../utilities/hooks'; -import { - scaleBand as d3ScaleBand, - scaleLinear as d3ScaleLinear, - scaleLog as d3ScaleLog, - scaleTime as d3ScaleTime, - scaleUtc as d3ScaleUtc, - ScaleBand, - ScaleContinuousNumeric, - ScaleTime, -} from 'd3-scale'; -import { extent as d3Extent } from 'd3-array'; import { pointRadial as d3PointRadial, areaRadial as d3AreaRadial, lineRadial as d3LineRadial, curveLinearClosed as d3CurveLinearClosed, } from 'd3-shape'; -import { AxisScaleType, DataPointV2 } from '../../types/DataPoint'; +import { DataPointV2 } from '../../types/DataPoint'; import { tokens } from '@fluentui/react-theme'; import { Legend, Legends } from '../Legends/index'; -import { getNextColor } from '../../utilities/colors'; +import { getColorFromToken, getNextColor } from '../../utilities/colors'; +import { createScale, getDomain, getScaleType } from './PolarChart.utils'; +import { ChartPopover } from '../CommonComponents/ChartPopover'; const DEFAULT_LEGEND_HEIGHT = 32; +const LABEL_WIDTH = 36; +const LABEL_HEIGHT = 16; +const LABEL_OFFSET = 4; export const PolarChart: React.FunctionComponent = React.forwardRef( (props, forwardedRef) => { @@ -38,6 +32,15 @@ export const PolarChart: React.FunctionComponent = React.forwar const [legendContainerHeight, setLegendContainerHeight] = React.useState( props.hideLegend ? 0 : DEFAULT_LEGEND_HEIGHT, ); + const [isPopoverOpen, setPopoverOpen] = React.useState(false); + const [popoverTarget, setPopoverTarget] = React.useState(null); + const [popoverXValue, setPopoverXValue] = React.useState(''); + const [popoverLegend, setPopoverLegend] = React.useState(''); + const [popoverColor, setPopoverColor] = React.useState(''); + const [popoverYValue, setPopoverYValue] = React.useState(''); + const [hoveredLegend, setHoveredLegend] = React.useState(''); + const [selectedLegends, setSelectedLegends] = React.useState(props.legendProps?.selectedLegends || []); + React.useEffect(() => { if (chartContainerRef.current) { const { width, height } = chartContainerRef.current.getBoundingClientRect(); @@ -53,13 +56,17 @@ export const PolarChart: React.FunctionComponent = React.forwar const { marginTop } = getComputedStyle(legendContainerRef.current); setLegendContainerHeight(Math.max(height, DEFAULT_LEGEND_HEIGHT) + parseFloat(marginTop)); } - }, [props.hideLegend, props.legendProps?.enabledWrapLines]); + }, [props.hideLegend]); + + React.useEffect(() => { + setSelectedLegends(props.legendProps?.selectedLegends || []); + }, [JSON.stringify(props.legendProps?.selectedLegends)]); const margins = { - left: 0, - right: 0, - top: 0, - bottom: 0, + left: LABEL_OFFSET + LABEL_WIDTH, + right: LABEL_OFFSET + LABEL_WIDTH, + top: LABEL_OFFSET + LABEL_HEIGHT, + bottom: LABEL_OFFSET + LABEL_HEIGHT, ...props.margins, }; @@ -69,7 +76,27 @@ export const PolarChart: React.FunctionComponent = React.forwar const outerRadius = Math.min(svgWidth - (margins.left + margins.right), svgHeight - (margins.top + margins.bottom)) / 2; - const xValues = props.data.flatMap(series => series.data.map(point => point.x)); + const legendColorMap: Record = {}; + let colorIndex = 0; + const chartData = props.data.map(series => { + const seriesColor = series.color ? getColorFromToken(series.color) : getNextColor(colorIndex++, 0); + if (!(series.legend in legendColorMap)) { + legendColorMap[series.legend] = seriesColor; + } + + return { + ...series, + color: seriesColor, + data: series.data.map(point => { + return { + ...point, + color: point.color ? getColorFromToken(point.color) : seriesColor, + }; + }), + }; + }); + + const xValues = chartData.flatMap(series => series.data.map(point => point.x)); const xScaleType = getScaleType(xValues, { scaleType: props.xScaleType, supportsLog: true, @@ -77,7 +104,7 @@ export const PolarChart: React.FunctionComponent = React.forwar const xDomain = getDomain(xScaleType, xValues); const xScale = createScale(xScaleType, xDomain, [innerRadius, outerRadius], { innerPadding: 1 }); - const yValues = props.data.flatMap(series => series.data.map(point => point.y)); + const yValues = chartData.flatMap(series => series.data.map(point => point.y)); const yScaleType = getScaleType(yValues, { scaleType: props.yScaleType, supportsLog: true, @@ -93,7 +120,7 @@ export const PolarChart: React.FunctionComponent = React.forwar return ( <> - {('ticks' in xScale ? xScale.ticks() : xScale.domain()).map((xTickValue, xTickIndex) => { + {('ticks' in xScale ? xScale.ticks(3) : xScale.domain()).map((xTickValue, xTickIndex) => { if (props.shape === 'polygon') { let d = ''; ('ticks' in yScale ? yScale.ticks() : yScale.domain().slice(0, -1)).forEach( @@ -153,9 +180,9 @@ export const PolarChart: React.FunctionComponent = React.forwar return ( <> - {('ticks' in xScale ? xScale.ticks() : xScale.domain()).map((xTickValue, xTickIndex) => { + {('ticks' in xScale ? xScale.ticks(3) : xScale.domain()).map((xTickValue, xTickIndex) => { return ( - + {`${xTickValue}`} ); @@ -163,10 +190,18 @@ export const PolarChart: React.FunctionComponent = React.forwar {('ticks' in yScale ? yScale.ticks() : yScale.domain().slice(0, -1)).map((yTickValue, yTickIndex) => { - const radialPoint = d3PointRadial(yScale(yTickValue as any)!, outerRadius); + const angle = yScale(yTickValue as any)!; + const radialPoint = d3PointRadial(angle, outerRadius); return ( - + Math.PI ? 'end' : 'start'} + dominantBaseline={angle > Math.PI / 2 && angle < (3 * Math.PI) / 2 ? 'hanging' : 'auto'} + aria-hidden={true} + > {`${yTickValue}`} ); @@ -179,7 +214,7 @@ export const PolarChart: React.FunctionComponent = React.forwar const renderRadialAreas = () => { return ( - {props.data + {chartData .filter(series => series.type === 'area') .map((series, seriesIndex) => { const radialArea = d3AreaRadial>() @@ -197,7 +232,7 @@ export const PolarChart: React.FunctionComponent = React.forwar const renderRadialLines = () => { return ( - {props.data + {chartData .filter(series => series.type === 'line') .map((series, seriesIndex) => { const radialLine = d3LineRadial>() @@ -222,14 +257,27 @@ export const PolarChart: React.FunctionComponent = React.forwar const renderRadialPoints = () => { return ( - {props.data + {chartData .filter(series => series.type === 'scatter') .map((series, seriesIndex) => { return ( - + {series.data.map((point, pointIndex) => { const radialPoint = d3PointRadial(yScale(point.y as any)!, xScale(point.x as any)!); - return ; + return ( + showPopover(e, point, series.legend, point.color)} + onFocus={e => showPopover(e, point, series.legend, point.color)} + /> + ); })} ); @@ -243,27 +291,25 @@ export const PolarChart: React.FunctionComponent = React.forwar return null; } - const legends: Legend[] = props.data.map((series, index) => { - const color: string = series.color || getNextColor(index, 0, false); - + const legends: Legend[] = Object.keys(legendColorMap).map(legendTitle => { return { - title: series.legend, - color, + title: legendTitle, + color: legendColorMap[legendTitle], hoverAction: () => { - // setHoveredLegend(series.legend); + setHoveredLegend(legendTitle); }, onMouseOutAction: () => { - // setHoveredLegend(''); + setHoveredLegend(''); }, }; }); return ( -
+
@@ -271,8 +317,53 @@ export const PolarChart: React.FunctionComponent = React.forwar ); }; + const showPopover = ( + event: React.MouseEvent | React.FocusEvent, + point: DataPointV2, + legend: string, + color: string, + ) => { + setPopoverTarget(event.currentTarget); + setPopoverOpen(noLegendHighlighted() || legendHighlighted(legend)); + setPopoverXValue(point.xAxisCalloutData ?? `${point.x}`); + setPopoverLegend(legend); + setPopoverColor(color); + setPopoverYValue(point.yAxisCalloutData ?? `${point.y}`); + }; + + const hidePopover = () => { + setPopoverOpen(false); + }; + + const onLegendSelectionChange = ( + _selectedLegends: string[], + event: React.MouseEvent, + currentLegend?: Legend, + ) => { + if (props.legendProps?.canSelectMultipleLegends) { + setSelectedLegends(_selectedLegends); + } else { + setSelectedLegends(_selectedLegends.slice(-1)); + } + if (props.legendProps?.onChange) { + props.legendProps.onChange(_selectedLegends, event, currentLegend); + } + }; + + const legendHighlighted = (legendTitle: string) => { + return getHighlightedLegend().includes(legendTitle); + }; + + const noLegendHighlighted = () => { + return getHighlightedLegend().length === 0; + }; + + const getHighlightedLegend = () => { + return selectedLegends.length > 0 ? selectedLegends : hoveredLegend ? [hoveredLegend] : []; + }; + return ( -
+
= React.forwar height={svgHeight} viewBox={`${-svgWidth / 2} ${-svgHeight / 2} ${svgWidth} ${svgHeight}`} role="region" + aria-label="" > {renderPolarGrid()} {renderRadialAreas()} @@ -289,79 +381,21 @@ export const PolarChart: React.FunctionComponent = React.forwar
{renderLegends()} + {!props.hideTooltip && ( + + )}
); }, ); PolarChart.displayName = 'PolarChart'; - -const createScale = ( - scaleType: string, - domain: (string | number | Date)[], - range: number[], - opts: { - useUTC?: boolean; - niceBounds?: boolean; - innerPadding?: number; - } = {}, -) => { - if (scaleType === 'category') { - return d3ScaleBand() - .domain(domain as string[]) - .range(range) - .paddingInner(opts.innerPadding || 0); - } - - let scale: ScaleContinuousNumeric | ScaleTime; - switch (scaleType) { - case 'log': - scale = d3ScaleLog(); - break; - case 'date': - scale = opts.useUTC ? d3ScaleUtc() : d3ScaleTime(); - break; - default: - scale = d3ScaleLinear(); - } - - scale.domain(domain as (number | Date)[]); - scale.range(range); - if (opts.niceBounds) { - scale.nice(); - } - - return scale; -}; - -const getScaleType = ( - values: (string | number | Date)[], - opts: { - scaleType?: AxisScaleType; - supportsLog?: boolean; - } = {}, -) => { - let scaleType = 'category'; - if (typeof values[0] === 'number') { - if (opts.supportsLog && opts.scaleType === 'log') { - scaleType = 'log'; - } else { - scaleType = 'linear'; - } - } else if (values[0] instanceof Date) { - scaleType = 'date'; - } - return scaleType; -}; - -const getDomain = (scaleType: string, values: (string | number | Date)[], opts: {} = {}) => { - if (scaleType === 'category') { - return Array.from(new Set(values)); - } - - const extent = d3Extent(values as (number | Date)[]); - if (typeof extent[0] !== 'undefined' && typeof extent[1] !== 'undefined') { - return extent; - } - return []; -}; diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts new file mode 100644 index 00000000000000..171f91b0e86909 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts @@ -0,0 +1,81 @@ +import { + scaleBand as d3ScaleBand, + scaleLinear as d3ScaleLinear, + scaleLog as d3ScaleLog, + scaleTime as d3ScaleTime, + scaleUtc as d3ScaleUtc, + ScaleContinuousNumeric, + ScaleTime, +} from 'd3-scale'; +import { extent as d3Extent } from 'd3-array'; +import { AxisScaleType } from '../../types/DataPoint'; + +export const createScale = ( + scaleType: string, + domain: (string | number | Date)[], + range: number[], + opts: { + useUTC?: boolean; + niceBounds?: boolean; + innerPadding?: number; + } = {}, +) => { + if (scaleType === 'category') { + return d3ScaleBand() + .domain(domain as string[]) + .range(range) + .paddingInner(opts.innerPadding || 0); + } + + let scale: ScaleContinuousNumeric | ScaleTime; + switch (scaleType) { + case 'log': + scale = d3ScaleLog(); + break; + case 'date': + scale = opts.useUTC ? d3ScaleUtc() : d3ScaleTime(); + break; + default: + scale = d3ScaleLinear(); + } + + scale.domain(domain as (number | Date)[]); + scale.range(range); + if (opts.niceBounds) { + scale.nice(); + } + + return scale; +}; + +export const getScaleType = ( + values: (string | number | Date)[], + opts: { + scaleType?: AxisScaleType; + supportsLog?: boolean; + } = {}, +) => { + let scaleType = 'category'; + if (typeof values[0] === 'number') { + if (opts.supportsLog && opts.scaleType === 'log') { + scaleType = 'log'; + } else { + scaleType = 'linear'; + } + } else if (values[0] instanceof Date) { + scaleType = 'date'; + } + return scaleType; +}; + +export const getDomain = (scaleType: string, values: (string | number | Date)[], opts: {} = {}) => { + if (scaleType === 'category') { + return Array.from(new Set(values)); + } + + const extent = d3Extent(values as (number | Date)[]); + if (typeof extent[0] !== 'undefined' && typeof extent[1] !== 'undefined') { + return extent; + } + return []; +}; diff --git a/packages/charts/react-charts/stories/src/PolarChart/PolarChartDefault.stories.tsx b/packages/charts/react-charts/stories/src/PolarChart/PolarChartDefault.stories.tsx index ad6e1dd0fa3710..ac9cbeb4bca240 100644 --- a/packages/charts/react-charts/stories/src/PolarChart/PolarChartDefault.stories.tsx +++ b/packages/charts/react-charts/stories/src/PolarChart/PolarChartDefault.stories.tsx @@ -7,27 +7,25 @@ const data: PolarChartProps['data'] = [ { type: 'scatter', legend: 'Mike', - color: 'red', data: [ - { x: 120, y: 'Math' }, - { x: 98, y: 'Chinese' }, - { x: 86, y: 'English' }, - { x: 99, y: 'Geography' }, - { x: 85, y: 'Physics' }, - { x: 65, y: 'History' }, + { x: 120, y: 'Math', markerSize: 6 }, + { x: 98, y: 'Chinese', markerSize: 8 }, + { x: 86, y: 'English', markerSize: 10 }, + { x: 99, y: 'Geography', markerSize: 12 }, + { x: 85, y: 'Physics', markerSize: 14 }, + { x: 65, y: 'History', markerSize: 16 }, ], }, { type: 'scatter', legend: 'Lily', - color: 'blue', data: [ - { x: 110, y: 'Math' }, - { x: 130, y: 'Chinese' }, - { x: 130, y: 'English' }, - { x: 100, y: 'Geography' }, - { x: 90, y: 'Physics' }, - { x: 85, y: 'History' }, + { x: 110, y: 'Math', markerSize: 6 }, + { x: 130, y: 'Chinese', markerSize: 8 }, + { x: 130, y: 'English', markerSize: 10 }, + { x: 100, y: 'Geography', markerSize: 12 }, + { x: 90, y: 'Physics', markerSize: 14 }, + { x: 85, y: 'History', markerSize: 16 }, ], }, ]; @@ -111,7 +109,7 @@ export const PolarChartBasic = (): JSXElement => { // showYAxisLables width={width} height={height} - shape="polygon" + // shape="polygon" // enableGradient={enableGradient} // roundCorners={roundedCorners} /> From 2d3321fa368eabe2c0ae82187c5113756cc638cc Mon Sep 17 00:00:00 2001 From: Kumar Kshitij Date: Tue, 23 Dec 2025 18:15:11 +0530 Subject: [PATCH 03/12] refactor --- .../DeclarativeChart/DeclarativeChart.tsx | 46 +- .../DeclarativeChart/PlotlySchemaAdapter.ts | 307 +++++----- .../components/PolarChart/PolarChart.test.tsx | 0 .../src/components/PolarChart/PolarChart.tsx | 540 +++++++++++------- .../components/PolarChart/PolarChart.types.ts | 24 +- .../components/PolarChart/PolarChart.utils.ts | 26 +- .../PolarChart/usePolarChartStyles.styles.ts | 48 +- .../library/src/types/DataPoint.ts | 95 ++- .../PolarChart/PolarChartDefault.stories.tsx | 30 +- 9 files changed, 694 insertions(+), 422 deletions(-) delete mode 100644 packages/charts/react-charts/library/src/components/PolarChart/PolarChart.test.tsx diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx index c94d1e40b513c0..4c4798e176e765 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx @@ -32,13 +32,13 @@ import { transformPlotlyJsonToVBCProps, transformPlotlyJsonToChartTableProps, transformPlotlyJsonToScatterChartProps, - projectPolarToCartesian, getAllupLegendsProps, NON_PLOT_KEY_PREFIX, SINGLE_REPEAT, transformPlotlyJsonToFunnelChartProps, transformPlotlyJsonToGanttChartProps, transformPlotlyJsonToAnnotationChartProps, + transformPlotlyJsonToPolarChartProps, } from './PlotlySchemaAdapter'; import type { ColorwayType } from './PlotlyColorAdapter'; import { AnnotationOnlyChart } from '../AnnotationOnlyChart/AnnotationOnlyChart'; @@ -56,6 +56,7 @@ import { Chart, ImageExportOptions } from '../../types/index'; import { ScatterChart } from '../ScatterChart/index'; import { FunnelChart } from '../FunnelChart/FunnelChart'; import { GanttChart } from '../GanttChart/index'; +import { PolarChart } from '../PolarChart/index'; import { withResponsiveContainer } from '../ResponsiveContainer/withResponsiveContainer'; import { ChartTable } from '../ChartTable/index'; @@ -79,6 +80,7 @@ const ResponsiveChartTable = withResponsiveContainer(ChartTable); const ResponsiveGanttChart = withResponsiveContainer(GanttChart); // Removing responsive wrapper for FunnelChart as responsive container is not working with FunnelChart //const ResponsiveFunnelChart = withResponsiveContainer(FunnelChart); +const ResponsivePolarChart = withResponsiveContainer(PolarChart); // Default x-axis key for grouping traces. Also applicable for PieData and SankeyData where x-axis is not defined. const DEFAULT_XAXIS = 'x'; @@ -243,6 +245,10 @@ type ChartTypeMap = { transformer: typeof transformPlotlyJsonToFunnelChartProps; renderer: typeof FunnelChart; } & PreTransformHooks; + scatterpolar: { + transformer: typeof transformPlotlyJsonToPolarChartProps; + renderer: typeof ResponsivePolarChart; + } & PreTransformHooks; fallback: { transformer: typeof transformPlotlyJsonToVSBCProps; renderer: typeof ResponsiveVerticalStackedBarChart; @@ -317,6 +323,10 @@ const chartMap: ChartTypeMap = { transformer: transformPlotlyJsonToFunnelChartProps, renderer: FunnelChart, }, + scatterpolar: { + transformer: transformPlotlyJsonToPolarChartProps, + renderer: ResponsivePolarChart, + }, fallback: { transformer: transformPlotlyJsonToVSBCProps, renderer: ResponsiveVerticalStackedBarChart, @@ -458,23 +468,23 @@ export const DeclarativeChart: React.FunctionComponent = [exportAsImage], ); - if (chart.type === 'scatterpolar') { - const cartesianProjection = projectPolarToCartesian(plotlyInputWithValidData); - plotlyInputWithValidData.data = cartesianProjection.data; - plotlyInputWithValidData.layout = cartesianProjection.layout; - validTracesFilteredIndex.forEach((trace, index) => { - if (trace.type === 'scatterpolar') { - const mode = (plotlyInputWithValidData.data[index] as PlotData)?.mode ?? ''; - if (mode.includes('line')) { - validTracesFilteredIndex[index].type = 'line'; - } else if (mode.includes('markers') || mode === 'text') { - validTracesFilteredIndex[index].type = 'scatter'; - } else { - validTracesFilteredIndex[index].type = 'line'; - } - } - }); - } + // if (chart.type === 'scatterpolar') { + // const cartesianProjection = projectPolarToCartesian(plotlyInputWithValidData); + // plotlyInputWithValidData.data = cartesianProjection.data; + // plotlyInputWithValidData.layout = cartesianProjection.layout; + // validTracesFilteredIndex.forEach((trace, index) => { + // if (trace.type === 'scatterpolar') { + // const mode = (plotlyInputWithValidData.data[index] as PlotData)?.mode ?? ''; + // if (mode.includes('line')) { + // validTracesFilteredIndex[index].type = 'line'; + // } else if (mode.includes('markers') || mode === 'text') { + // validTracesFilteredIndex[index].type = 'scatter'; + // } else { + // validTracesFilteredIndex[index].type = 'line'; + // } + // } + // }); + // } const groupedTraces: Record = {}; let nonCartesianTraceCount = 0; diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts index 4f7f9da3933dab..03a8fedb604f9d 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts @@ -94,6 +94,7 @@ import { Legend, LegendsProps } from '../Legends/index'; import { ScatterChartProps } from '../ScatterChart/ScatterChart.types'; import { CartesianChartProps } from '../CommonComponents/index'; import { FunnelChartDataPoint, FunnelChartProps } from '../FunnelChart/FunnelChart.types'; +import { PolarChartProps } from '../PolarChart/PolarChart.types'; import { ChartAnnotation, ChartAnnotationArrowHead, @@ -2993,156 +2994,168 @@ export const transformPlotlyJsonToFunnelChartProps = ( }; }; -export const projectPolarToCartesian = (input: PlotlySchema): PlotlySchema => { - const projection: PlotlySchema = { ...input }; - - // Find the global min and max radius across all series - let minRadius = 0; - let maxRadius = 0; - for (let sindex = 0; sindex < input.data.length; sindex++) { - const rVals = (input.data[sindex] as Partial).r; - if (rVals && isArrayOrTypedArray(rVals)) { - for (let ptindex = 0; ptindex < rVals.length; ptindex++) { - if (!isInvalidValue(rVals[ptindex])) { - minRadius = Math.min(minRadius, rVals[ptindex] as number); - maxRadius = Math.max(maxRadius, rVals[ptindex] as number); - } - } - } - } - - // If there are negative radii, compute the shift - const radiusShift = minRadius < 0 ? -minRadius : 0; - - // Collect all unique theta values from all scatterpolar series for equal spacing - const allThetaValues: Set = new Set(); - for (let sindex = 0; sindex < input.data.length; sindex++) { - const series = input.data[sindex] as Partial; - if (series.theta && isArrayOrTypedArray(series.theta)) { - series.theta.forEach(theta => allThetaValues.add(String(theta))); - } - } - - // Project all points and create a perfect square domain - const allX: number[] = []; - const allY: number[] = []; - let originX: number | null = null; - for (let sindex = 0; sindex < input.data.length; sindex++) { - const series = input.data[sindex] as Partial; - // If scatterpolar, set __axisLabel to all unique theta values for equal spacing - if (isArrayOrTypedArray(series.theta)) { - (series as { __axisLabel: string[] }).__axisLabel = Array.from(allThetaValues); - } - series.x = [] as Datum[]; - series.y = [] as Datum[]; - const thetas = series.theta!; - const rVals = series.r!; - - // Skip if rVals or thetas are not arrays - if (!isArrayOrTypedArray(rVals) || !isArrayOrTypedArray(thetas)) { - projection.data[sindex] = series; - continue; - } - - // retrieve polar axis settings - const dirMultiplier = input.layout?.polar?.angularaxis?.direction === 'clockwise' ? -1 : 1; - const startAngleInRad = ((input.layout?.polar?.angularaxis?.rotation ?? 0) * Math.PI) / 180; - - // Compute tick positions if categorical - let uniqueTheta: Datum[] = []; - let categorical = false; - if (!isNumberArray(thetas)) { - uniqueTheta = Array.from(new Set(thetas)); - categorical = true; - } - - for (let ptindex = 0; ptindex < rVals.length; ptindex++) { - if (isInvalidValue(thetas?.[ptindex]) || isInvalidValue(rVals?.[ptindex])) { - continue; - } - - // Map theta to angle in radians - let thetaRad: number; - if (categorical) { - const idx = uniqueTheta.indexOf(thetas[ptindex]); - const step = (2 * Math.PI) / uniqueTheta.length; - thetaRad = startAngleInRad + dirMultiplier * idx * step; - } else { - thetaRad = startAngleInRad + dirMultiplier * (((thetas[ptindex] as number) * Math.PI) / 180); - } - // Shift only the polar origin (not the cartesian) - const rawRadius = rVals[ptindex] as number; - const polarRadius = rawRadius + radiusShift; // Only for projection - // Calculate cartesian coordinates (with shifted polar origin) - const x = polarRadius * Math.cos(thetaRad); - const y = polarRadius * Math.sin(thetaRad); - - // Calculate the cartesian coordinates of the original polar origin (0,0) - // This is the point that should be mapped to (0,0) in cartesian coordinates - if (sindex === 0 && ptindex === 0) { - // For polar origin (r=0, θ=0), cartesian coordinates are (0,0) - // But since we shifted the radius by radiusShift, the cartesian origin is at (radiusShift, 0) - originX = radiusShift; - } - - series.x.push(x); - series.y.push(y); - allX.push(x); - allY.push(y); - } - - // Map text to each data point for downstream chart rendering - if (series.x && series.y) { - (series as { data?: unknown[] }).data = series.x.map((xVal, idx) => ({ - x: xVal, - y: (series.y as number[])[idx], - ...(series.text ? { text: (series.text as string[])[idx] } : {}), - })); - } - - projection.data[sindex] = series; - } - - // 7. Recenter all cartesian coordinates - if (originX !== null) { - for (let sindex = 0; sindex < projection.data.length; sindex++) { - const series = projection.data[sindex] as Partial; - if (series.x && series.y) { - series.x = (series.x as number[]).map((v: number) => v - originX!); - } - } - // Also recenter allX for normalization - for (let i = 0; i < allX.length; i++) { - allX[i] = allX[i] - originX!; - } - } - - // 8. Find the maximum absolute value among all x and y - let maxAbs = Math.max(...allX.map(Math.abs), ...allY.map(Math.abs)); - maxAbs = maxAbs === 0 ? 1 : maxAbs; - - // 9. Rescale all points so that the largest |x| or |y| is 0.5 - for (let sindex = 0; sindex < projection.data.length; sindex++) { - const series = projection.data[sindex] as Partial; - if (series.x && series.y) { - series.x = (series.x as number[]).map((v: number) => v / (2 * maxAbs)); - series.y = (series.y as number[]).map((v: number) => v / (2 * maxAbs)); - } - } - - // 10. Customize layout for perfect square with absolute positioning - const size = input.layout?.width || input.layout?.height || 500; - projection.layout = { - ...projection.layout, - width: size, - height: size, +export const transformPlotlyJsonToPolarChartProps = ( + input: PlotlySchema, + isMultiPlot: boolean, + colorMap: React.RefObject>, + colorwayType: ColorwayType, + isDarkTheme?: boolean, +): PolarChartProps => { + return { + data: [], }; - // Attach originX as custom properties - (projection.layout as { __polarOriginX?: number }).__polarOriginX = originX ?? undefined; - - return projection; }; +// export const projectPolarToCartesian = (input: PlotlySchema): PlotlySchema => { +// const projection: PlotlySchema = { ...input }; + +// // Find the global min and max radius across all series +// let minRadius = 0; +// let maxRadius = 0; +// for (let sindex = 0; sindex < input.data.length; sindex++) { +// const rVals = (input.data[sindex] as Partial).r; +// if (rVals && isArrayOrTypedArray(rVals)) { +// for (let ptindex = 0; ptindex < rVals.length; ptindex++) { +// if (!isInvalidValue(rVals[ptindex])) { +// minRadius = Math.min(minRadius, rVals[ptindex] as number); +// maxRadius = Math.max(maxRadius, rVals[ptindex] as number); +// } +// } +// } +// } + +// // If there are negative radii, compute the shift +// const radiusShift = minRadius < 0 ? -minRadius : 0; + +// // Collect all unique theta values from all scatterpolar series for equal spacing +// const allThetaValues: Set = new Set(); +// for (let sindex = 0; sindex < input.data.length; sindex++) { +// const series = input.data[sindex] as Partial; +// if (series.theta && isArrayOrTypedArray(series.theta)) { +// series.theta.forEach(theta => allThetaValues.add(String(theta))); +// } +// } + +// // Project all points and create a perfect square domain +// const allX: number[] = []; +// const allY: number[] = []; +// let originX: number | null = null; +// for (let sindex = 0; sindex < input.data.length; sindex++) { +// const series = input.data[sindex] as Partial; +// // If scatterpolar, set __axisLabel to all unique theta values for equal spacing +// if (isArrayOrTypedArray(series.theta)) { +// (series as { __axisLabel: string[] }).__axisLabel = Array.from(allThetaValues); +// } +// series.x = [] as Datum[]; +// series.y = [] as Datum[]; +// const thetas = series.theta!; +// const rVals = series.r!; + +// // Skip if rVals or thetas are not arrays +// if (!isArrayOrTypedArray(rVals) || !isArrayOrTypedArray(thetas)) { +// projection.data[sindex] = series; +// continue; +// } + +// // retrieve polar axis settings +// const dirMultiplier = input.layout?.polar?.angularaxis?.direction === 'clockwise' ? -1 : 1; +// const startAngleInRad = ((input.layout?.polar?.angularaxis?.rotation ?? 0) * Math.PI) / 180; + +// // Compute tick positions if categorical +// let uniqueTheta: Datum[] = []; +// let categorical = false; +// if (!isNumberArray(thetas)) { +// uniqueTheta = Array.from(new Set(thetas)); +// categorical = true; +// } + +// for (let ptindex = 0; ptindex < rVals.length; ptindex++) { +// if (isInvalidValue(thetas?.[ptindex]) || isInvalidValue(rVals?.[ptindex])) { +// continue; +// } + +// // Map theta to angle in radians +// let thetaRad: number; +// if (categorical) { +// const idx = uniqueTheta.indexOf(thetas[ptindex]); +// const step = (2 * Math.PI) / uniqueTheta.length; +// thetaRad = startAngleInRad + dirMultiplier * idx * step; +// } else { +// thetaRad = startAngleInRad + dirMultiplier * (((thetas[ptindex] as number) * Math.PI) / 180); +// } +// // Shift only the polar origin (not the cartesian) +// const rawRadius = rVals[ptindex] as number; +// const polarRadius = rawRadius + radiusShift; // Only for projection +// // Calculate cartesian coordinates (with shifted polar origin) +// const x = polarRadius * Math.cos(thetaRad); +// const y = polarRadius * Math.sin(thetaRad); + +// // Calculate the cartesian coordinates of the original polar origin (0,0) +// // This is the point that should be mapped to (0,0) in cartesian coordinates +// if (sindex === 0 && ptindex === 0) { +// // For polar origin (r=0, θ=0), cartesian coordinates are (0,0) +// // But since we shifted the radius by radiusShift, the cartesian origin is at (radiusShift, 0) +// originX = radiusShift; +// } + +// series.x.push(x); +// series.y.push(y); +// allX.push(x); +// allY.push(y); +// } + +// // Map text to each data point for downstream chart rendering +// if (series.x && series.y) { +// (series as { data?: unknown[] }).data = series.x.map((xVal, idx) => ({ +// x: xVal, +// y: (series.y as number[])[idx], +// ...(series.text ? { text: (series.text as string[])[idx] } : {}), +// })); +// } + +// projection.data[sindex] = series; +// } + +// // 7. Recenter all cartesian coordinates +// if (originX !== null) { +// for (let sindex = 0; sindex < projection.data.length; sindex++) { +// const series = projection.data[sindex] as Partial; +// if (series.x && series.y) { +// series.x = (series.x as number[]).map((v: number) => v - originX!); +// } +// } +// // Also recenter allX for normalization +// for (let i = 0; i < allX.length; i++) { +// allX[i] = allX[i] - originX!; +// } +// } + +// // 8. Find the maximum absolute value among all x and y +// let maxAbs = Math.max(...allX.map(Math.abs), ...allY.map(Math.abs)); +// maxAbs = maxAbs === 0 ? 1 : maxAbs; + +// // 9. Rescale all points so that the largest |x| or |y| is 0.5 +// for (let sindex = 0; sindex < projection.data.length; sindex++) { +// const series = projection.data[sindex] as Partial; +// if (series.x && series.y) { +// series.x = (series.x as number[]).map((v: number) => v / (2 * maxAbs)); +// series.y = (series.y as number[]).map((v: number) => v / (2 * maxAbs)); +// } +// } + +// // 10. Customize layout for perfect square with absolute positioning +// const size = input.layout?.width || input.layout?.height || 500; +// projection.layout = { +// ...projection.layout, +// width: size, +// height: size, +// }; +// // Attach originX as custom properties +// (projection.layout as { __polarOriginX?: number }).__polarOriginX = originX ?? undefined; + +// return projection; +// }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any function isPlainObject(obj: any) { return ( diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.test.tsx b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.test.tsx deleted file mode 100644 index e69de29bb2d1d6..00000000000000 diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx index 8afcec7215aa80..6a98cbb2069e8c 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx @@ -4,23 +4,22 @@ import * as React from 'react'; import { PolarChartProps } from './PolarChart.types'; import { usePolarChartStyles } from './usePolarChartStyles.styles'; import { useImageExport } from '../../utilities/hooks'; -import { - pointRadial as d3PointRadial, - areaRadial as d3AreaRadial, - lineRadial as d3LineRadial, - curveLinearClosed as d3CurveLinearClosed, -} from 'd3-shape'; -import { DataPointV2 } from '../../types/DataPoint'; +import { pointRadial as d3PointRadial, areaRadial as d3AreaRadial, lineRadial as d3LineRadial } from 'd3-shape'; +import { PolarDataPoint } from '../../types/DataPoint'; import { tokens } from '@fluentui/react-theme'; import { Legend, Legends } from '../Legends/index'; -import { getColorFromToken, getNextColor } from '../../utilities/colors'; import { createScale, getDomain, getScaleType } from './PolarChart.utils'; import { ChartPopover } from '../CommonComponents/ChartPopover'; +import { getCurveFactory, getColorFromToken, getNextColor } from '../../utilities/index'; +import { extent as d3Extent } from 'd3-array'; const DEFAULT_LEGEND_HEIGHT = 32; const LABEL_WIDTH = 36; const LABEL_HEIGHT = 16; -const LABEL_OFFSET = 4; +const LABEL_OFFSET = 8; +const EPSILON = 1e-6; +const MIN_PIXEL = 4; +const MAX_PIXEL = 16; export const PolarChart: React.FunctionComponent = React.forwardRef( (props, forwardedRef) => { @@ -40,6 +39,7 @@ export const PolarChart: React.FunctionComponent = React.forwar const [popoverYValue, setPopoverYValue] = React.useState(''); const [hoveredLegend, setHoveredLegend] = React.useState(''); const [selectedLegends, setSelectedLegends] = React.useState(props.legendProps?.selectedLegends || []); + const [activePoint, setActivePoint] = React.useState(''); React.useEffect(() => { if (chartContainerRef.current) { @@ -62,13 +62,16 @@ export const PolarChart: React.FunctionComponent = React.forwar setSelectedLegends(props.legendProps?.selectedLegends || []); }, [JSON.stringify(props.legendProps?.selectedLegends)]); - const margins = { - left: LABEL_OFFSET + LABEL_WIDTH, - right: LABEL_OFFSET + LABEL_WIDTH, - top: LABEL_OFFSET + LABEL_HEIGHT, - bottom: LABEL_OFFSET + LABEL_HEIGHT, - ...props.margins, - }; + const margins = React.useMemo( + () => ({ + left: LABEL_OFFSET + LABEL_WIDTH, + right: LABEL_OFFSET + LABEL_WIDTH, + top: LABEL_OFFSET + LABEL_HEIGHT, + bottom: LABEL_OFFSET + LABEL_HEIGHT, + ...props.margins, + }), + [props.margins], + ); const svgWidth = props.width || containerWidth; const svgHeight = (props.height || containerHeight) - legendContainerHeight; @@ -76,87 +79,108 @@ export const PolarChart: React.FunctionComponent = React.forwar const outerRadius = Math.min(svgWidth - (margins.left + margins.right), svgHeight - (margins.top + margins.bottom)) / 2; - const legendColorMap: Record = {}; - let colorIndex = 0; - const chartData = props.data.map(series => { - const seriesColor = series.color ? getColorFromToken(series.color) : getNextColor(colorIndex++, 0); - if (!(series.legend in legendColorMap)) { - legendColorMap[series.legend] = seriesColor; - } + const legendColorMap = React.useRef>({}); + const chartData = React.useMemo(() => { + legendColorMap.current = {}; + let colorIndex = 0; + + return props.data.map(series => { + const seriesColor = series.color ? getColorFromToken(series.color) : getNextColor(colorIndex++, 0); + if (!(series.legend in legendColorMap.current)) { + legendColorMap.current[series.legend] = seriesColor; + } - return { - ...series, - color: seriesColor, - data: series.data.map(point => { - return { - ...point, - color: point.color ? getColorFromToken(point.color) : seriesColor, - }; + return { + ...series, + color: seriesColor, + data: series.data.map(point => { + return { + ...point, + color: point.color ? getColorFromToken(point.color) : seriesColor, + }; + }), + }; + }); + }, [props.data]); + + const xValues = React.useMemo(() => chartData.flatMap(series => series.data.map(point => point.r)), [chartData]); + const xScaleType = React.useMemo( + () => + getScaleType(xValues, { + scaleType: props.xScaleType, + supportsLog: true, + }), + [xValues, props.xScaleType], + ); + const xDomain = React.useMemo(() => getDomain(xScaleType, xValues), [xScaleType, xValues]); + const xScale = React.useMemo( + () => createScale(xScaleType, xDomain, [innerRadius, outerRadius], { innerPadding: 1 }), + [xScaleType, xDomain, innerRadius, outerRadius], + ); + const xTickValues = React.useMemo(() => ('ticks' in xScale ? xScale.ticks(3) : xScale.domain()), [xScale]); + const xTickFormat = React.useCallback((x: string | number | Date) => `${x}`, []); + + const yValues = React.useMemo( + () => chartData.flatMap(series => series.data.map(point => point.theta)), + [chartData], + ); + const yScaleType = React.useMemo( + () => + getScaleType(yValues, { + scaleType: props.yScaleType, + supportsLog: true, }), - }; - }); - - const xValues = chartData.flatMap(series => series.data.map(point => point.x)); - const xScaleType = getScaleType(xValues, { - scaleType: props.xScaleType, - supportsLog: true, - }); - const xDomain = getDomain(xScaleType, xValues); - const xScale = createScale(xScaleType, xDomain, [innerRadius, outerRadius], { innerPadding: 1 }); - - const yValues = chartData.flatMap(series => series.data.map(point => point.y)); - const yScaleType = getScaleType(yValues, { - scaleType: props.yScaleType, - supportsLog: true, - }); - const yDomain = getDomain(yScaleType, yValues); - const yScale = createScale(yScaleType, yScaleType === 'category' ? [...yDomain, ''] : yDomain, [0, 2 * Math.PI], { - innerPadding: 1, - }); + [yValues, props.yScaleType], + ); + const yDomain = React.useMemo(() => getDomain(yScaleType, yValues), [yScaleType, yValues]); + const yScale = React.useMemo( + () => + createScale(yScaleType, yScaleType === 'category' ? [...yDomain, ''] : yDomain, [0, 2 * Math.PI], { + innerPadding: 1, + }), + [yScaleType, yDomain], + ); + const yTickValues = React.useMemo( + () => ('ticks' in yScale ? yScale.ticks() : yScale.domain().slice(0, -1)), + [yScale], + ); + const yTickFormat = React.useCallback((y: string | number | Date) => `${y}`, []); const classes = usePolarChartStyles(props); - const renderPolarGrid = () => { + const renderPolarGrid = React.useCallback(() => { + const extXTickValues = []; + if (innerRadius > 0 && xDomain[0] !== xTickValues[0]) { + extXTickValues.push(xDomain[0]); + } + extXTickValues.push(...xTickValues); + if (xDomain[xDomain.length - 1] !== xTickValues[xTickValues.length - 1]) { + extXTickValues.push(xDomain[xDomain.length - 1]); + } + return ( - <> + - {('ticks' in xScale ? xScale.ticks(3) : xScale.domain()).map((xTickValue, xTickIndex) => { + {extXTickValues.map((xTickValue, xTickIndex) => { + const className = + xTickIndex === extXTickValues.length - 1 ? classes.gridLineOuter : classes.gridLineInner; + if (props.shape === 'polygon') { let d = ''; - ('ticks' in yScale ? yScale.ticks() : yScale.domain().slice(0, -1)).forEach( - (yTickValue, yTickIndex) => { - const radialPoint = d3PointRadial(yScale(yTickValue as any)!, xScale(xTickValue as any)!); - d += (yTickIndex === 0 ? 'M' : 'L') + radialPoint.join(',') + ' '; - }, - ); + yTickValues.forEach((yTickValue, yTickIndex) => { + const radialPoint = d3PointRadial(yScale(yTickValue as any)!, xScale(xTickValue as any)!); + d += (yTickIndex === 0 ? 'M' : 'L') + radialPoint.join(',') + ' '; + }); d += 'Z'; - return ( - - ); + return ; } - return ( - - ); + return ; })} - {('ticks' in yScale ? yScale.ticks() : yScale.domain().slice(0, -1)).map((yTickValue, yTickIndex) => { + {yTickValues.map((yTickValue, yTickIndex) => { const radialPoint1 = d3PointRadial(yScale(yTickValue as any)!, innerRadius); const radialPoint2 = d3PointRadial(yScale(yTickValue as any)!, outerRadius); @@ -164,137 +188,213 @@ export const PolarChart: React.FunctionComponent = React.forwar ); })} - + ); - }; + }, []); - const renderPolarTicks = () => { + const renderPolarTicks = React.useCallback(() => { return ( - <> + - {('ticks' in xScale ? xScale.ticks(3) : xScale.domain()).map((xTickValue, xTickIndex) => { + {xTickValues.map((xTickValue, xTickIndex) => { return ( - - {`${xTickValue}`} + + {xTickFormat(xTickValue)} ); })} - {('ticks' in yScale ? yScale.ticks() : yScale.domain().slice(0, -1)).map((yTickValue, yTickIndex) => { + {yTickValues.map((yTickValue, yTickIndex) => { const angle = yScale(yTickValue as any)!; - const radialPoint = d3PointRadial(angle, outerRadius); + const [pointX, pointY] = d3PointRadial(angle, outerRadius + LABEL_OFFSET); return ( Math.PI ? 'end' : 'start'} - dominantBaseline={angle > Math.PI / 2 && angle < (3 * Math.PI) / 2 ? 'hanging' : 'auto'} + x={pointX} + y={pointY} + textAnchor={ + Math.abs(angle) < EPSILON || Math.abs(angle - Math.PI) < EPSILON + ? 'middle' + : angle > Math.PI + ? 'end' + : 'start' + } + dominantBaseline="middle" aria-hidden={true} + className={classes.tickLabel} > - {`${yTickValue}`} + {yTickFormat(yTickValue)} ); })} - - ); - }; - - const renderRadialAreas = () => { - return ( - - {chartData - .filter(series => series.type === 'area') - .map((series, seriesIndex) => { - const radialArea = d3AreaRadial>() - .angle(d => yScale(d.y as any)!) - .innerRadius(innerRadius) - .outerRadius(d => xScale(d.x as any)!) - .curve(d3CurveLinearClosed); - - return ; - })} ); - }; + }, []); - const renderRadialLines = () => { - return ( - - {chartData - .filter(series => series.type === 'line') - .map((series, seriesIndex) => { - const radialLine = d3LineRadial>() - .angle(d => yScale(d.y as any)!) - .radius(d => xScale(d.x as any)!) - .curve(d3CurveLinearClosed); + const getActiveLegends = React.useCallback(() => { + return selectedLegends.length > 0 ? selectedLegends : hoveredLegend ? [hoveredLegend] : []; + }, [selectedLegends, hoveredLegend]); + + const legendHighlighted = React.useCallback( + (legendTitle: string) => { + const activeLegends = getActiveLegends(); + return activeLegends.includes(legendTitle) || activeLegends.length === 0; + }, + [getActiveLegends], + ); - return ( - - ); - })} - - ); - }; + const renderRadialAreas = React.useCallback(() => { + const areaData = chartData.filter(series => series.type === 'areapolar'); - const renderRadialPoints = () => { - return ( - - {chartData - .filter(series => series.type === 'scatter') - .map((series, seriesIndex) => { - return ( - - {series.data.map((point, pointIndex) => { - const radialPoint = d3PointRadial(yScale(point.y as any)!, xScale(point.x as any)!); - return ( - showPopover(e, point, series.legend, point.color)} - onFocus={e => showPopover(e, point, series.legend, point.color)} - /> - ); - })} - - ); - })} - - ); - }; + return areaData.map((series, seriesIndex) => { + const radialArea = d3AreaRadial() + .angle(d => yScale(d.theta as any)!) + .innerRadius(innerRadius) + .outerRadius(d => xScale(d.r as any)!) + .curve(getCurveFactory(series.lineOptions?.curve)); + const shouldHighlight = legendHighlighted(series.legend); + + return ( + + + {renderRadialPoints([series])} + + ); + }); + }, [legendHighlighted]); + + const renderRadialLines = React.useCallback(() => { + const lineData = chartData.filter(series => series.type === 'linepolar'); + + return lineData.map((series, seriesIndex) => { + const radialLine = d3LineRadial() + .angle(d => yScale(d.theta as any)!) + .radius(d => xScale(d.r as any)!) + .curve(getCurveFactory(series.lineOptions?.curve)); - const renderLegends = () => { + return ( + + + {renderRadialPoints([series])} + + ); + }); + }, [legendHighlighted]); + + const [minMarkerSize, maxMarkerSize] = React.useMemo( + () => d3Extent(chartData.flatMap(series => series.data.map(point => point.markerSize as number))), + [chartData], + ); + + const renderRadialPoints = React.useCallback( + (scatterData: typeof chartData) => { + return scatterData.map((series, seriesIndex) => { + const shouldHighlight = legendHighlighted(series.legend); + return ( + + {series.data.map((point, pointIndex) => { + const [x, y] = d3PointRadial(yScale(point.theta as any)!, xScale(point.r as any)!); + const id = `${seriesIndex}-${pointIndex}`; + const isActive = activePoint === id; + let radius = isActive ? 6 : MIN_PIXEL; + if (typeof point.markerSize !== 'undefined' && minMarkerSize !== maxMarkerSize) { + radius = + MIN_PIXEL + + ((point.markerSize - minMarkerSize!) / (maxMarkerSize! - minMarkerSize!)) * (MAX_PIXEL - MIN_PIXEL); + } + + const xValue = point.radialAxisCalloutData || xTickFormat(point.r); + const legend = series.legend; + const yValue = point.angularAxisCalloutData || yTickFormat(point.theta); + const ariaLabel = point.callOutAccessibilityData?.ariaLabel || `${xValue}. ${legend}, ${yValue}.`; + + return ( + showPopover(e, point, id, series.legend)} + onFocus={e => showPopover(e, point, id, series.legend)} + role="img" + aria-label={ariaLabel} + /> + ); + })} + + ); + }); + }, + [legendHighlighted], + ); + + const renderLegends = React.useCallback(() => { if (props.hideLegend) { return null; } - const legends: Legend[] = Object.keys(legendColorMap).map(legendTitle => { + const legends: Legend[] = Object.keys(legendColorMap.current).map(legendTitle => { return { title: legendTitle, - color: legendColorMap[legendTitle], + color: legendColorMap.current[legendTitle], hoverAction: () => { setHoveredLegend(legendTitle); }, @@ -315,52 +415,44 @@ export const PolarChart: React.FunctionComponent = React.forwar />
); - }; - - const showPopover = ( - event: React.MouseEvent | React.FocusEvent, - point: DataPointV2, - legend: string, - color: string, - ) => { - setPopoverTarget(event.currentTarget); - setPopoverOpen(noLegendHighlighted() || legendHighlighted(legend)); - setPopoverXValue(point.xAxisCalloutData ?? `${point.x}`); - setPopoverLegend(legend); - setPopoverColor(color); - setPopoverYValue(point.yAxisCalloutData ?? `${point.y}`); - }; - - const hidePopover = () => { - setPopoverOpen(false); - }; - - const onLegendSelectionChange = ( - _selectedLegends: string[], - event: React.MouseEvent, - currentLegend?: Legend, - ) => { - if (props.legendProps?.canSelectMultipleLegends) { - setSelectedLegends(_selectedLegends); - } else { - setSelectedLegends(_selectedLegends.slice(-1)); - } - if (props.legendProps?.onChange) { - props.legendProps.onChange(_selectedLegends, event, currentLegend); - } - }; + }, []); - const legendHighlighted = (legendTitle: string) => { - return getHighlightedLegend().includes(legendTitle); - }; + const showPopover = React.useCallback( + ( + event: React.MouseEvent | React.FocusEvent, + point: PolarDataPoint, + pointId: string, + legend: string, + ) => { + setPopoverTarget(event.currentTarget); + setPopoverOpen(legendHighlighted(legend)); + setPopoverXValue(point.radialAxisCalloutData ?? xTickFormat(point.r)); + setPopoverLegend(legend); + setPopoverColor(point.color!); + setPopoverYValue(point.angularAxisCalloutData ?? yTickFormat(point.theta)); + setActivePoint(pointId); + }, + [], + ); - const noLegendHighlighted = () => { - return getHighlightedLegend().length === 0; - }; + const hidePopover = React.useCallback(() => { + setPopoverOpen(false); + setActivePoint(''); + }, []); - const getHighlightedLegend = () => { - return selectedLegends.length > 0 ? selectedLegends : hoveredLegend ? [hoveredLegend] : []; - }; + const onLegendSelectionChange = React.useCallback( + (_selectedLegends: string[], event: React.MouseEvent, currentLegend?: Legend) => { + if (props.legendProps?.canSelectMultipleLegends) { + setSelectedLegends(_selectedLegends); + } else { + setSelectedLegends(_selectedLegends.slice(-1)); + } + if (props.legendProps?.onChange) { + props.legendProps.onChange(_selectedLegends, event, currentLegend); + } + }, + [], + ); return (
@@ -371,12 +463,16 @@ export const PolarChart: React.FunctionComponent = React.forwar height={svgHeight} viewBox={`${-svgWidth / 2} ${-svgHeight / 2} ${svgWidth} ${svgHeight}`} role="region" - aria-label="" + aria-label={ + (props.chartTitle ? `${props.chartTitle}. ` : '') + `Polar chart with ${chartData.length} data series.` + } > {renderPolarGrid()} - {renderRadialAreas()} - {renderRadialLines()} - {renderRadialPoints()} + + {renderRadialAreas()} + {renderRadialLines()} + {renderRadialPoints(chartData.filter(series => series.type === 'scatterpolar'))} + {renderPolarTicks()}
diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts index b90547185b6c6a..c37a1d69c6e335 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts @@ -3,7 +3,7 @@ import { CartesianChartStyleProps, CartesianChartStyles, } from '../CommonComponents/CartesianChart.types'; -import { AreaSeries, LineSeries, ScatterSeries } from '../../types/DataPoint'; +import { AreaPolarSeries, LinePolarSeries, ScatterPolarSeries } from '../../types/DataPoint'; /** * Polar Chart properties @@ -13,10 +13,7 @@ export interface PolarChartProps extends CartesianChartProps { /** * */ - data: - | AreaSeries[] - | LineSeries[] - | ScatterSeries[]; + data: AreaPolarSeries[] | LinePolarSeries[] | ScatterPolarSeries[]; /** * @@ -44,4 +41,19 @@ export interface PolarChartStyleProps extends CartesianChartStyleProps {} * Polar Chart styles * {@docCategory PolarChart} */ -export interface PolarChartStyles extends CartesianChartStyles {} +export interface PolarChartStyles extends CartesianChartStyles { + /** + * + */ + gridLineInner?: string; + + /** + * + */ + gridLineOuter?: string; + + /** + * + */ + tickLabel?: string; +} diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts index 171f91b0e86909..4aa8ec06c83253 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts @@ -18,6 +18,11 @@ export const createScale = ( useUTC?: boolean; niceBounds?: boolean; innerPadding?: number; + tickCount?: number; + hideTickOverlap?: boolean; + tickValues?: (string | number | Date)[]; + tickText?: string[]; + tickFormat?: () => string; } = {}, ) => { if (scaleType === 'category') { @@ -68,14 +73,27 @@ export const getScaleType = ( return scaleType; }; -export const getDomain = (scaleType: string, values: (string | number | Date)[], opts: {} = {}) => { +export const getDomain = ( + scaleType: string, + values: (string | number | Date)[], + opts: { + start?: number | Date; + end?: number | Date; + } = {}, +) => { if (scaleType === 'category') { return Array.from(new Set(values)); } - const extent = d3Extent(values as (number | Date)[]); - if (typeof extent[0] !== 'undefined' && typeof extent[1] !== 'undefined') { - return extent; + let [min, max] = d3Extent(values as (number | Date)[]); + if (typeof opts.start !== 'undefined') { + min = opts.start; + } + if (typeof opts.end !== 'undefined') { + max = opts.end; + } + if (typeof min !== 'undefined' && typeof max !== 'undefined') { + return [min, max]; } return []; }; diff --git a/packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts b/packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts index 06546eca5cf7be..d39cff6e4c90a2 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts +++ b/packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts @@ -1,5 +1,7 @@ +import { makeStyles, mergeClasses } from '@griffel/react'; import { PolarChartStyles, PolarChartProps } from './PolarChart.types'; import type { SlotClassNames } from '@fluentui/react-utilities'; +import { tokens, typographyStyles } from '@fluentui/react-theme'; /** * @internal @@ -22,11 +24,55 @@ export const polarChartClassNames: SlotClassNames = { axisAnnotation: '', plotContainer: '', annotationLayer: '', + gridLineInner: 'fui-polar__gridLineInner', + gridLineOuter: 'fui-polar__gridLineOuter', + tickLabel: 'fui-polar__tickLabel', }; +const useStyles = makeStyles({ + gridLine: { + fill: 'none', + stroke: tokens.colorNeutralForeground1, + strokeWidth: '1px', + }, + + gridLineInner: { + opacity: 0.2, + }, + + gridLineOuter: { + opacity: 1, + }, + + tickLabel: { + ...typographyStyles.caption2Strong, + fill: tokens.colorNeutralForeground1, + }, +}); + /** * Apply styling to the PolarChart component */ export const usePolarChartStyles = (props: PolarChartProps): PolarChartStyles => { - return {}; + const baseStyles = useStyles(); + + return { + gridLineInner: mergeClasses( + polarChartClassNames.gridLineInner, + baseStyles.gridLine, + baseStyles.gridLineInner, + // props.styles?.gridLineInner, + ), + gridLineOuter: mergeClasses( + polarChartClassNames.gridLineOuter, + baseStyles.gridLine, + baseStyles.gridLineOuter, + // props.styles?.gridLineOuter, + ), + tickLabel: mergeClasses( + polarChartClassNames.tickLabel, + baseStyles.tickLabel, + // props.styles?.tickLabel + ), + }; }; diff --git a/packages/charts/react-charts/library/src/types/DataPoint.ts b/packages/charts/react-charts/library/src/types/DataPoint.ts index 90bb0fc6affa9e..6d2b9cfd893fbf 100644 --- a/packages/charts/react-charts/library/src/types/DataPoint.ts +++ b/packages/charts/react-charts/library/src/types/DataPoint.ts @@ -1252,31 +1252,106 @@ export interface LineSeries extends DataSeries { +export interface PolarDataPoint { + /** + * + */ + r: number; + + /** + * + */ + theta: string | number; + + /** + * Optional click handler for the data point. + */ + onClick?: () => void; + + /** + * Custom text to show in the callout in place of the radial axis value. + */ + radialAxisCalloutData?: string; + /** - * Type discriminator: always 'scatter' for this series. + * Custom text to show in the callout in place of the angular axis value. */ - type: 'scatter'; + angularAxisCalloutData?: string; + + /** + * Accessibility properties for the data point. + */ + callOutAccessibilityData?: AccessibilityProps; + + /** + * Custom marker size for the data point. + */ + markerSize?: number; + + /** + * Optional text to annotate or label the data point. + */ + text?: string; + + /** + * Color of the data point. If not provided, it will inherit the series color. + */ + color?: string; +} + +/** + * Represents a scatterpolar series. + */ +export interface ScatterPolarSeries extends DataSeries { + /** + * Type discriminator: always 'scatterpolar' for this series. + */ + type: 'scatterpolar'; /** * Array of data points for the series. */ - data: DataPointV2[]; + data: PolarDataPoint[]; } /** - * Represents a area series. + * Represents a linepolar series. */ -export interface AreaSeries extends DataSeries { +export interface LinePolarSeries extends DataSeries { /** - * Type discriminator: always 'area' for this series. + * Type discriminator: always 'linepolar' for this series. */ - type: 'area'; + type: 'linepolar'; /** * Array of data points for the series. */ - data: DataPointV2[]; + data: PolarDataPoint[]; + + /** + * Additional line rendering options (e.g., stroke width, curve type). + */ + lineOptions?: LineChartLineOptions; +} + +/** + * Represents a areapolar series. + */ +export interface AreaPolarSeries extends DataSeries { + /** + * Type discriminator: always 'areapolar' for this series. + */ + type: 'areapolar'; + + /** + * Array of data points for the series. + */ + data: PolarDataPoint[]; + + /** + * Additional line rendering options (e.g., stroke width, curve type). + */ + lineOptions?: LineChartLineOptions; } diff --git a/packages/charts/react-charts/stories/src/PolarChart/PolarChartDefault.stories.tsx b/packages/charts/react-charts/stories/src/PolarChart/PolarChartDefault.stories.tsx index ac9cbeb4bca240..b86083b3cf5ebc 100644 --- a/packages/charts/react-charts/stories/src/PolarChart/PolarChartDefault.stories.tsx +++ b/packages/charts/react-charts/stories/src/PolarChart/PolarChartDefault.stories.tsx @@ -5,27 +5,29 @@ import { Switch } from '@fluentui/react-components'; const data: PolarChartProps['data'] = [ { - type: 'scatter', + type: 'areapolar', legend: 'Mike', + color: 'rgb(255, 0, 0)', data: [ - { x: 120, y: 'Math', markerSize: 6 }, - { x: 98, y: 'Chinese', markerSize: 8 }, - { x: 86, y: 'English', markerSize: 10 }, - { x: 99, y: 'Geography', markerSize: 12 }, - { x: 85, y: 'Physics', markerSize: 14 }, - { x: 65, y: 'History', markerSize: 16 }, + { r: 120, theta: 'Math', markerSize: 6 }, + { r: 98, theta: 'Chinese', markerSize: 8 }, + { r: 86, theta: 'English', markerSize: 10 }, + { r: 99, theta: 'Geography', markerSize: 12 }, + { r: 85, theta: 'Physics', markerSize: 14 }, + { r: 65, theta: 'History', markerSize: 16 }, ], }, { - type: 'scatter', + type: 'areapolar', legend: 'Lily', + color: 'rgb(0, 0, 255)', data: [ - { x: 110, y: 'Math', markerSize: 6 }, - { x: 130, y: 'Chinese', markerSize: 8 }, - { x: 130, y: 'English', markerSize: 10 }, - { x: 100, y: 'Geography', markerSize: 12 }, - { x: 90, y: 'Physics', markerSize: 14 }, - { x: 85, y: 'History', markerSize: 16 }, + { r: 110, theta: 'Math', markerSize: 6 }, + { r: 130, theta: 'Chinese', markerSize: 8 }, + { r: 130, theta: 'English', markerSize: 10 }, + { r: 100, theta: 'Geography', markerSize: 12 }, + { r: 90, theta: 'Physics', markerSize: 14 }, + { r: 85, theta: 'History', markerSize: 16 }, ], }, ]; From 8e0b73a6950620338ca7aaaa5b401a0918919ff8 Mon Sep 17 00:00:00 2001 From: Kumar Kshitij Date: Wed, 24 Dec 2025 03:15:40 +0530 Subject: [PATCH 04/12] add PolarChart support to DeclarativeChart --- .../DeclarativeChart/DeclarativeChart.tsx | 17 -- .../DeclarativeChart/PlotlySchemaAdapter.ts | 236 +++++++----------- .../src/components/PolarChart/PolarChart.tsx | 98 ++++---- .../components/PolarChart/PolarChart.types.ts | 2 +- 4 files changed, 141 insertions(+), 212 deletions(-) diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx index 4c4798e176e765..edfdaff73da17b 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx @@ -468,23 +468,6 @@ export const DeclarativeChart: React.FunctionComponent = [exportAsImage], ); - // if (chart.type === 'scatterpolar') { - // const cartesianProjection = projectPolarToCartesian(plotlyInputWithValidData); - // plotlyInputWithValidData.data = cartesianProjection.data; - // plotlyInputWithValidData.layout = cartesianProjection.layout; - // validTracesFilteredIndex.forEach((trace, index) => { - // if (trace.type === 'scatterpolar') { - // const mode = (plotlyInputWithValidData.data[index] as PlotData)?.mode ?? ''; - // if (mode.includes('line')) { - // validTracesFilteredIndex[index].type = 'line'; - // } else if (mode.includes('markers') || mode === 'text') { - // validTracesFilteredIndex[index].type = 'scatter'; - // } else { - // validTracesFilteredIndex[index].type = 'line'; - // } - // } - // }); - // } const groupedTraces: Record = {}; let nonCartesianTraceCount = 0; diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts index 03a8fedb604f9d..7a34a87924be76 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts @@ -3001,161 +3001,95 @@ export const transformPlotlyJsonToPolarChartProps = ( colorwayType: ColorwayType, isDarkTheme?: boolean, ): PolarChartProps => { + const polarData: PolarChartProps['data'] = []; + const { legends, hideLegend } = getLegendProps(input.data, input.layout, isMultiPlot); + const thetaunit = (input.layout?.polar?.angularaxis as { thetaunit?: 'radians' | 'degrees' } | undefined)?.thetaunit; + + input.data.forEach((series: Partial, index: number) => { + const legend = legends[index]; + + if (series.type === 'scatterpolar') { + const isAreaTrace = series.fill === 'toself' || series.fill === 'tonext'; + const isLineTrace = typeof series.mode === 'undefined' ? true : series.mode.includes('lines'); + const colors = isAreaTrace ? series.fillcolor : isLineTrace ? series.line?.color : series.marker?.color; + const extractedColors = extractColor( + input.layout?.template?.layout?.colorway, + colorwayType, + colors, + colorMap, + isDarkTheme, + ); + const seriesColor = resolveColor( + extractedColors, + index, + legend, + colorMap, + input.layout?.template?.layout?.colorway, + isDarkTheme, + ); + const seriesOpacity = getOpacity(series, index); + const finalSeriesColor = rgb(seriesColor).copy({ opacity: seriesOpacity }).formatHex8(); + const lineOptions = getLineOptions(series.line); + + const commonProps = { + legend, + legendShape: getLegendShape(series), + color: finalSeriesColor, + data: + series.r + ?.map((r, rIndex) => { + const theta = series.theta?.[rIndex]; + const markerSize = Array.isArray(series.marker?.size) ? series.marker.size[rIndex] : series.marker?.size; + const text = Array.isArray(series.text) ? series.text[rIndex] : series.text; + const markerColor = resolveColor( + extractedColors, + rIndex, + legend, + colorMap, + input.layout?.template?.layout?.colorway, + isDarkTheme, + ); + const markerOpacity = getOpacity(series, rIndex); + + if (isInvalidValue(r) || isInvalidValue(theta)) { + return; + } + + return { + r: r as number, + theta: + typeof theta === 'number' && thetaunit === 'radians' ? (theta * 180) / Math.PI : (theta as string), + color: markerColor ? rgb(markerColor).copy({ opacity: markerOpacity }).formatHex8() : finalSeriesColor, + ...(typeof markerSize !== 'undefined' ? { markerSize } : {}), + ...(typeof text !== 'undefined' ? { text } : {}), + }; + }) + .filter(item => typeof item !== 'undefined') || [], + }; + + if (isAreaTrace || isLineTrace) { + polarData.push({ + type: isAreaTrace ? 'areapolar' : 'linepolar', + ...commonProps, + lineOptions, + }); + } else { + polarData.push({ + type: 'scatterpolar', + ...commonProps, + }); + } + } + }); + return { - data: [], + data: polarData, + width: input.layout?.width, + height: input.layout?.height ?? 400, + hideLegend, }; }; -// export const projectPolarToCartesian = (input: PlotlySchema): PlotlySchema => { -// const projection: PlotlySchema = { ...input }; - -// // Find the global min and max radius across all series -// let minRadius = 0; -// let maxRadius = 0; -// for (let sindex = 0; sindex < input.data.length; sindex++) { -// const rVals = (input.data[sindex] as Partial).r; -// if (rVals && isArrayOrTypedArray(rVals)) { -// for (let ptindex = 0; ptindex < rVals.length; ptindex++) { -// if (!isInvalidValue(rVals[ptindex])) { -// minRadius = Math.min(minRadius, rVals[ptindex] as number); -// maxRadius = Math.max(maxRadius, rVals[ptindex] as number); -// } -// } -// } -// } - -// // If there are negative radii, compute the shift -// const radiusShift = minRadius < 0 ? -minRadius : 0; - -// // Collect all unique theta values from all scatterpolar series for equal spacing -// const allThetaValues: Set = new Set(); -// for (let sindex = 0; sindex < input.data.length; sindex++) { -// const series = input.data[sindex] as Partial; -// if (series.theta && isArrayOrTypedArray(series.theta)) { -// series.theta.forEach(theta => allThetaValues.add(String(theta))); -// } -// } - -// // Project all points and create a perfect square domain -// const allX: number[] = []; -// const allY: number[] = []; -// let originX: number | null = null; -// for (let sindex = 0; sindex < input.data.length; sindex++) { -// const series = input.data[sindex] as Partial; -// // If scatterpolar, set __axisLabel to all unique theta values for equal spacing -// if (isArrayOrTypedArray(series.theta)) { -// (series as { __axisLabel: string[] }).__axisLabel = Array.from(allThetaValues); -// } -// series.x = [] as Datum[]; -// series.y = [] as Datum[]; -// const thetas = series.theta!; -// const rVals = series.r!; - -// // Skip if rVals or thetas are not arrays -// if (!isArrayOrTypedArray(rVals) || !isArrayOrTypedArray(thetas)) { -// projection.data[sindex] = series; -// continue; -// } - -// // retrieve polar axis settings -// const dirMultiplier = input.layout?.polar?.angularaxis?.direction === 'clockwise' ? -1 : 1; -// const startAngleInRad = ((input.layout?.polar?.angularaxis?.rotation ?? 0) * Math.PI) / 180; - -// // Compute tick positions if categorical -// let uniqueTheta: Datum[] = []; -// let categorical = false; -// if (!isNumberArray(thetas)) { -// uniqueTheta = Array.from(new Set(thetas)); -// categorical = true; -// } - -// for (let ptindex = 0; ptindex < rVals.length; ptindex++) { -// if (isInvalidValue(thetas?.[ptindex]) || isInvalidValue(rVals?.[ptindex])) { -// continue; -// } - -// // Map theta to angle in radians -// let thetaRad: number; -// if (categorical) { -// const idx = uniqueTheta.indexOf(thetas[ptindex]); -// const step = (2 * Math.PI) / uniqueTheta.length; -// thetaRad = startAngleInRad + dirMultiplier * idx * step; -// } else { -// thetaRad = startAngleInRad + dirMultiplier * (((thetas[ptindex] as number) * Math.PI) / 180); -// } -// // Shift only the polar origin (not the cartesian) -// const rawRadius = rVals[ptindex] as number; -// const polarRadius = rawRadius + radiusShift; // Only for projection -// // Calculate cartesian coordinates (with shifted polar origin) -// const x = polarRadius * Math.cos(thetaRad); -// const y = polarRadius * Math.sin(thetaRad); - -// // Calculate the cartesian coordinates of the original polar origin (0,0) -// // This is the point that should be mapped to (0,0) in cartesian coordinates -// if (sindex === 0 && ptindex === 0) { -// // For polar origin (r=0, θ=0), cartesian coordinates are (0,0) -// // But since we shifted the radius by radiusShift, the cartesian origin is at (radiusShift, 0) -// originX = radiusShift; -// } - -// series.x.push(x); -// series.y.push(y); -// allX.push(x); -// allY.push(y); -// } - -// // Map text to each data point for downstream chart rendering -// if (series.x && series.y) { -// (series as { data?: unknown[] }).data = series.x.map((xVal, idx) => ({ -// x: xVal, -// y: (series.y as number[])[idx], -// ...(series.text ? { text: (series.text as string[])[idx] } : {}), -// })); -// } - -// projection.data[sindex] = series; -// } - -// // 7. Recenter all cartesian coordinates -// if (originX !== null) { -// for (let sindex = 0; sindex < projection.data.length; sindex++) { -// const series = projection.data[sindex] as Partial; -// if (series.x && series.y) { -// series.x = (series.x as number[]).map((v: number) => v - originX!); -// } -// } -// // Also recenter allX for normalization -// for (let i = 0; i < allX.length; i++) { -// allX[i] = allX[i] - originX!; -// } -// } - -// // 8. Find the maximum absolute value among all x and y -// let maxAbs = Math.max(...allX.map(Math.abs), ...allY.map(Math.abs)); -// maxAbs = maxAbs === 0 ? 1 : maxAbs; - -// // 9. Rescale all points so that the largest |x| or |y| is 0.5 -// for (let sindex = 0; sindex < projection.data.length; sindex++) { -// const series = projection.data[sindex] as Partial; -// if (series.x && series.y) { -// series.x = (series.x as number[]).map((v: number) => v / (2 * maxAbs)); -// series.y = (series.y as number[]).map((v: number) => v / (2 * maxAbs)); -// } -// } - -// // 10. Customize layout for perfect square with absolute positioning -// const size = input.layout?.width || input.layout?.height || 500; -// projection.layout = { -// ...projection.layout, -// width: size, -// height: size, -// }; -// // Attach originX as custom properties -// (projection.layout as { __polarOriginX?: number }).__polarOriginX = originX ?? undefined; - -// return projection; -// }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any function isPlainObject(obj: any) { return ( diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx index 6a98cbb2069e8c..3e3536381df0f0 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx @@ -195,7 +195,7 @@ export const PolarChart: React.FunctionComponent = React.forwar ); - }, []); + }, [innerRadius, outerRadius, xDomain, xTickValues, yTickValues, xScale, yScale, props.shape, classes]); const renderPolarTicks = React.useCallback(() => { return ( @@ -245,7 +245,7 @@ export const PolarChart: React.FunctionComponent = React.forwar ); - }, []); + }, [xTickValues, yTickValues, xScale, yScale, outerRadius, xTickFormat, yTickFormat, classes]); const getActiveLegends = React.useCallback(() => { return selectedLegends.length > 0 ? selectedLegends : hoveredLegend ? [hoveredLegend] : []; @@ -288,12 +288,13 @@ export const PolarChart: React.FunctionComponent = React.forwar strokeDasharray={series.lineOptions?.strokeDasharray} strokeDashoffset={series.lineOptions?.strokeDashoffset} strokeLinecap={series.lineOptions?.strokeLinecap} + pointerEvents="none" /> {renderRadialPoints([series])} ); }); - }, [legendHighlighted]); + }, [chartData, innerRadius, xScale, yScale, legendHighlighted]); const renderRadialLines = React.useCallback(() => { const lineData = chartData.filter(series => series.type === 'linepolar'); @@ -321,18 +322,42 @@ export const PolarChart: React.FunctionComponent = React.forwar strokeDasharray={series.lineOptions?.strokeDasharray} strokeDashoffset={series.lineOptions?.strokeDashoffset} strokeLinecap={series.lineOptions?.strokeLinecap} + pointerEvents="none" /> {renderRadialPoints([series])} ); }); - }, [legendHighlighted]); + }, [chartData, xScale, yScale, legendHighlighted]); const [minMarkerSize, maxMarkerSize] = React.useMemo( () => d3Extent(chartData.flatMap(series => series.data.map(point => point.markerSize as number))), [chartData], ); + const showPopover = React.useCallback( + ( + event: React.MouseEvent | React.FocusEvent, + point: PolarDataPoint, + pointId: string, + legend: string, + ) => { + setPopoverTarget(event.currentTarget); + setPopoverOpen(legendHighlighted(legend)); + setPopoverXValue(point.radialAxisCalloutData ?? xTickFormat(point.r)); + setPopoverLegend(legend); + setPopoverColor(point.color!); + setPopoverYValue(point.angularAxisCalloutData ?? yTickFormat(point.theta)); + setActivePoint(pointId); + }, + [], + ); + + const hidePopover = React.useCallback(() => { + setPopoverOpen(false); + setActivePoint(''); + }, []); + const renderRadialPoints = React.useCallback( (scatterData: typeof chartData) => { return scatterData.map((series, seriesIndex) => { @@ -383,7 +408,31 @@ export const PolarChart: React.FunctionComponent = React.forwar ); }); }, - [legendHighlighted], + [ + legendHighlighted, + xScale, + yScale, + activePoint, + showPopover, + xTickFormat, + yTickFormat, + minMarkerSize, + maxMarkerSize, + ], + ); + + const onLegendSelectionChange = React.useCallback( + (_selectedLegends: string[], event: React.MouseEvent, currentLegend?: Legend) => { + if (props.legendProps?.canSelectMultipleLegends) { + setSelectedLegends(_selectedLegends); + } else { + setSelectedLegends(_selectedLegends.slice(-1)); + } + if (props.legendProps?.onChange) { + props.legendProps.onChange(_selectedLegends, event, currentLegend); + } + }, + [props.legendProps], ); const renderLegends = React.useCallback(() => { @@ -415,44 +464,7 @@ export const PolarChart: React.FunctionComponent = React.forwar />
); - }, []); - - const showPopover = React.useCallback( - ( - event: React.MouseEvent | React.FocusEvent, - point: PolarDataPoint, - pointId: string, - legend: string, - ) => { - setPopoverTarget(event.currentTarget); - setPopoverOpen(legendHighlighted(legend)); - setPopoverXValue(point.radialAxisCalloutData ?? xTickFormat(point.r)); - setPopoverLegend(legend); - setPopoverColor(point.color!); - setPopoverYValue(point.angularAxisCalloutData ?? yTickFormat(point.theta)); - setActivePoint(pointId); - }, - [], - ); - - const hidePopover = React.useCallback(() => { - setPopoverOpen(false); - setActivePoint(''); - }, []); - - const onLegendSelectionChange = React.useCallback( - (_selectedLegends: string[], event: React.MouseEvent, currentLegend?: Legend) => { - if (props.legendProps?.canSelectMultipleLegends) { - setSelectedLegends(_selectedLegends); - } else { - setSelectedLegends(_selectedLegends.slice(-1)); - } - if (props.legendProps?.onChange) { - props.legendProps.onChange(_selectedLegends, event, currentLegend); - } - }, - [], - ); + }, [props.hideLegend, props.legendProps, legendsRef, onLegendSelectionChange]); return (
diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts index c37a1d69c6e335..bd12036517c1e3 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts @@ -13,7 +13,7 @@ export interface PolarChartProps extends CartesianChartProps { /** * */ - data: AreaPolarSeries[] | LinePolarSeries[] | ScatterPolarSeries[]; + data: (AreaPolarSeries | LinePolarSeries | ScatterPolarSeries)[]; /** * From fcb416d344cf8af31009709bd440d3c29d4dddfb Mon Sep 17 00:00:00 2001 From: Kumar Kshitij Date: Tue, 6 Jan 2026 15:13:27 +0530 Subject: [PATCH 05/12] add axis props --- .../DeclarativeChart/PlotlySchemaAdapter.ts | 101 ++++++++- .../src/components/PolarChart/PolarChart.tsx | 184 ++++++++-------- .../components/PolarChart/PolarChart.types.ts | 151 ++++++++++++- .../components/PolarChart/PolarChart.utils.ts | 201 +++++++++++++++--- .../PolarChart/usePolarChartStyles.styles.ts | 20 +- .../library/src/types/DataPoint.ts | 2 +- .../library/src/utilities/utilities.ts | 6 +- 7 files changed, 520 insertions(+), 145 deletions(-) diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts index 7a34a87924be76..75648db90ccbee 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts @@ -94,7 +94,7 @@ import { Legend, LegendsProps } from '../Legends/index'; import { ScatterChartProps } from '../ScatterChart/ScatterChart.types'; import { CartesianChartProps } from '../CommonComponents/index'; import { FunnelChartDataPoint, FunnelChartProps } from '../FunnelChart/FunnelChart.types'; -import { PolarChartProps } from '../PolarChart/PolarChart.types'; +import { PolarAxisProps, PolarChartProps } from '../PolarChart/PolarChart.types'; import { ChartAnnotation, ChartAnnotationArrowHead, @@ -3087,6 +3087,7 @@ export const transformPlotlyJsonToPolarChartProps = ( width: input.layout?.width, height: input.layout?.height ?? 400, hideLegend, + ...getsomething(input.data, input.layout), }; }; @@ -4019,3 +4020,101 @@ const parseLocalDate = (value: string | number) => { } return new Date(value); }; + +const getAxisType2 = (values: Datum[], ax: Partial | undefined): AxisType => { + if (['linear', 'log', 'date', 'category'].includes(ax?.type ?? '')) { + return ax!.type!; + } + + if (isNumberArray(values) && !isYearArray(values)) { + return 'linear'; + } + if (isDateArray(values)) { + return 'date'; + } + return 'category'; +}; + +const getAxisTickProps2 = (values: Datum[], ax: Partial | undefined): PolarAxisProps => { + if (!ax) { + return {}; + } + + const props: PolarAxisProps = {}; + const axType = getAxisType2(values, ax); + + if ((!ax.tickmode || ax.tickmode === 'array') && isArrayOrTypedArray(ax.tickvals)) { + const tickValues = axType === 'date' ? ax.tickvals!.map((v: string | number | Date) => new Date(v)) : ax.tickvals; + + props.tickValues = tickValues; + props.tickText = ax.ticktext; + return props; + } + + if ((!ax.tickmode || ax.tickmode === 'linear') && ax.dtick) { + const dtick = plotlyDtick(ax.dtick, axType); + const tick0 = plotlyTick0(ax.tick0, axType, dtick); + + props.tickStep = dtick; + props.tick0 = tick0; + return props; + } + + if ((!ax.tickmode || ax.tickmode === 'auto') && typeof ax.nticks === 'number' && ax.nticks >= 0) { + props.tickCount = ax.nticks; + } + + return props; +}; + +const getAxisCategoryOrderProps2 = (values: Datum[], ax: Partial | undefined) => { + const axType = getAxisType2(values, ax); + if (axType !== 'category') { + return 'data'; + } + + const isValidArray = isArrayOrTypedArray(ax?.categoryarray) && ax!.categoryarray!.length > 0; + if (isValidArray && (!ax?.categoryorder || ax.categoryorder === 'array')) { + return ax!.categoryarray; + } + + if (!ax?.categoryorder || ax.categoryorder === 'trace' || ax.categoryorder === 'array') { + const categoriesInTraceOrder = Array.from(new Set(values as string[])); + return ax?.autorange === 'reversed' ? categoriesInTraceOrder.reverse() : categoriesInTraceOrder; + } + + return ax.categoryorder; +}; + +const getsomething = (data: Data[], layout: Partial | undefined) => { + const m = { + r: 'radialAxis', + theta: 'angularAxis', + }; + + const props = {}; + + Object.entries(m).forEach(([key, propName]) => { + const values: Datum[] = []; + data.forEach((series: Partial) => { + if (isArrayOrTypedArray(series[key])) { + series[key]!.forEach(val => { + if (!isInvalidValue(val)) { + values.push(val as Datum); + } + }); + } + }); + + const subplotId = (data[0] as { subplot?: string })?.subplot || 'polar'; + props[propName] = { + categoryOrder: getAxisCategoryOrderProps2(values, layout?.[subplotId]?.[propName.toLowerCase()]), + ...getAxisTickProps2(values, layout?.[subplotId]?.[propName.toLowerCase()]), + tickFormat: '', + title: '', + scaleType: '', + }; + }); + + return props; +}; diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx index 3e3536381df0f0..4e6b004e5a86a8 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx @@ -4,20 +4,24 @@ import * as React from 'react'; import { PolarChartProps } from './PolarChart.types'; import { usePolarChartStyles } from './usePolarChartStyles.styles'; import { useImageExport } from '../../utilities/hooks'; -import { pointRadial as d3PointRadial, areaRadial as d3AreaRadial, lineRadial as d3LineRadial } from 'd3-shape'; +import { + pointRadial as d3PointRadial, + areaRadial as d3AreaRadial, + lineRadial as d3LineRadial, + curveLinearClosed as d3CurveLinearClosed, +} from 'd3-shape'; import { PolarDataPoint } from '../../types/DataPoint'; import { tokens } from '@fluentui/react-theme'; import { Legend, Legends } from '../Legends/index'; -import { createScale, getDomain, getScaleType } from './PolarChart.utils'; +import { createRadialScale, getScaleDomain, getScaleType, EPSILON, createAngularScale } from './PolarChart.utils'; import { ChartPopover } from '../CommonComponents/ChartPopover'; -import { getCurveFactory, getColorFromToken, getNextColor } from '../../utilities/index'; +import { getColorFromToken, getNextColor } from '../../utilities/index'; import { extent as d3Extent } from 'd3-array'; const DEFAULT_LEGEND_HEIGHT = 32; const LABEL_WIDTH = 36; const LABEL_HEIGHT = 16; const LABEL_OFFSET = 8; -const EPSILON = 1e-6; const MIN_PIXEL = 4; const MAX_PIXEL = 16; @@ -75,9 +79,9 @@ export const PolarChart: React.FunctionComponent = React.forwar const svgWidth = props.width || containerWidth; const svgHeight = (props.height || containerHeight) - legendContainerHeight; - const innerRadius = props.innerRadius || 0; const outerRadius = Math.min(svgWidth - (margins.left + margins.right), svgHeight - (margins.top + margins.bottom)) / 2; + const innerRadius = Math.min(Math.abs(props.hole || 0), 1) * outerRadius; const legendColorMap = React.useRef>({}); const chartData = React.useMemo(() => { @@ -103,90 +107,114 @@ export const PolarChart: React.FunctionComponent = React.forwar }); }, [props.data]); - const xValues = React.useMemo(() => chartData.flatMap(series => series.data.map(point => point.r)), [chartData]); - const xScaleType = React.useMemo( + const rValues = React.useMemo(() => chartData.flatMap(series => series.data.map(point => point.r)), [chartData]); + const rScaleType = React.useMemo( () => - getScaleType(xValues, { - scaleType: props.xScaleType, + getScaleType(rValues, { + scaleType: props.radialAxis?.scaleType, supportsLog: true, }), - [xValues, props.xScaleType], + [rValues, props.radialAxis?.scaleType], ); - const xDomain = React.useMemo(() => getDomain(xScaleType, xValues), [xScaleType, xValues]); - const xScale = React.useMemo( - () => createScale(xScaleType, xDomain, [innerRadius, outerRadius], { innerPadding: 1 }), - [xScaleType, xDomain, innerRadius, outerRadius], + const rScaleDomain = React.useMemo( + () => + getScaleDomain(rScaleType, rValues, { + rangeStart: props.radialAxis?.rangeStart, + rangeEnd: props.radialAxis?.rangeEnd, + }), + [rScaleType, rValues, props.radialAxis?.rangeStart, props.radialAxis?.rangeEnd], + ); + const { + scale: rScale, + tickValues: rTickValues, + tickLabels: rTickLabels, + } = React.useMemo( + () => + createRadialScale(rScaleType, rScaleDomain, [innerRadius, outerRadius], { + useUTC: props.useUTC, + tickCount: props.radialAxis?.tickCount, + tickValues: props.radialAxis?.tickValues, + tickText: props.radialAxis?.tickText, + tickFormat: props.radialAxis?.tickFormat, + culture: props.culture, + tickStep: props.radialAxis?.tickStep, + tick0: props.radialAxis?.tick0, + dateLocalizeOptions: props.dateLocalizeOptions, + }), + [rScaleType, rScaleDomain, innerRadius, outerRadius], ); - const xTickValues = React.useMemo(() => ('ticks' in xScale ? xScale.ticks(3) : xScale.domain()), [xScale]); - const xTickFormat = React.useCallback((x: string | number | Date) => `${x}`, []); - const yValues = React.useMemo( + const aValues = React.useMemo( () => chartData.flatMap(series => series.data.map(point => point.theta)), [chartData], ); - const yScaleType = React.useMemo( + const aType = React.useMemo( () => - getScaleType(yValues, { - scaleType: props.yScaleType, + getScaleType(aValues, { + scaleType: props.angularAxis?.scaleType, supportsLog: true, }), - [yValues, props.yScaleType], + [aValues, props.angularAxis?.scaleType], ); - const yDomain = React.useMemo(() => getDomain(yScaleType, yValues), [yScaleType, yValues]); - const yScale = React.useMemo( + const aDomain = React.useMemo(() => getScaleDomain(aType, aValues), [aType, aValues]); + const { + scale: aScale, + tickValues: aTickValues, + tickLabels: aTickLabels, + } = React.useMemo( () => - createScale(yScaleType, yScaleType === 'category' ? [...yDomain, ''] : yDomain, [0, 2 * Math.PI], { - innerPadding: 1, + createAngularScale(aType, aDomain, { + tickCount: props.angularAxis?.tickCount, + tickValues: props.angularAxis?.tickValues, + tickText: props.angularAxis?.tickText, + tickFormat: props.angularAxis?.tickFormat, + culture: props.culture, + tickStep: props.angularAxis?.tickStep, + tick0: props.angularAxis?.tick0, }), - [yScaleType, yDomain], - ); - const yTickValues = React.useMemo( - () => ('ticks' in yScale ? yScale.ticks() : yScale.domain().slice(0, -1)), - [yScale], + [aType, aDomain], ); - const yTickFormat = React.useCallback((y: string | number | Date) => `${y}`, []); const classes = usePolarChartStyles(props); const renderPolarGrid = React.useCallback(() => { - const extXTickValues = []; - if (innerRadius > 0 && xDomain[0] !== xTickValues[0]) { - extXTickValues.push(xDomain[0]); + const extRTickValues = []; + if (innerRadius > 0 && rScaleDomain[0] !== rTickValues[0]) { + extRTickValues.push(rScaleDomain[0]); } - extXTickValues.push(...xTickValues); - if (xDomain[xDomain.length - 1] !== xTickValues[xTickValues.length - 1]) { - extXTickValues.push(xDomain[xDomain.length - 1]); + extRTickValues.push(...rTickValues); + if (rScaleDomain[rScaleDomain.length - 1] !== rTickValues[rTickValues.length - 1]) { + extRTickValues.push(rScaleDomain[rScaleDomain.length - 1]); } return ( - {extXTickValues.map((xTickValue, xTickIndex) => { - const className = - xTickIndex === extXTickValues.length - 1 ? classes.gridLineOuter : classes.gridLineInner; + {extRTickValues.map((r, rIndex) => { + const className = rIndex === extRTickValues.length - 1 ? classes.gridLineOuter : classes.gridLineInner; if (props.shape === 'polygon') { let d = ''; - yTickValues.forEach((yTickValue, yTickIndex) => { - const radialPoint = d3PointRadial(yScale(yTickValue as any)!, xScale(xTickValue as any)!); - d += (yTickIndex === 0 ? 'M' : 'L') + radialPoint.join(',') + ' '; + aTickValues.forEach((a, aIndex) => { + const radialPoint = d3PointRadial(aScale(a), rScale(r as any)!); + d += (aIndex === 0 ? 'M' : 'L') + radialPoint.join(',') + ' '; }); d += 'Z'; - return ; + return ; } - return ; + return ; })} - {yTickValues.map((yTickValue, yTickIndex) => { - const radialPoint1 = d3PointRadial(yScale(yTickValue as any)!, innerRadius); - const radialPoint2 = d3PointRadial(yScale(yTickValue as any)!, outerRadius); + {aTickValues.map((a, aIndex) => { + const radialPoint1 = d3PointRadial(aScale(a), innerRadius); + const radialPoint2 = d3PointRadial(aScale(a), outerRadius); return ( @@ -195,36 +223,36 @@ export const PolarChart: React.FunctionComponent = React.forwar ); - }, [innerRadius, outerRadius, xDomain, xTickValues, yTickValues, xScale, yScale, props.shape, classes]); + }, [innerRadius, outerRadius, rScaleDomain, rTickValues, aTickValues, rScale, aScale, props.shape, classes]); const renderPolarTicks = React.useCallback(() => { return ( - {xTickValues.map((xTickValue, xTickIndex) => { + {rTickValues.map((r, rIndex) => { return ( - {xTickFormat(xTickValue)} + {rTickLabels[rIndex]} ); })} - {yTickValues.map((yTickValue, yTickIndex) => { - const angle = yScale(yTickValue as any)!; + {aTickValues.map((a, aIndex) => { + const angle = aScale(a); const [pointX, pointY] = d3PointRadial(angle, outerRadius + LABEL_OFFSET); return ( = React.forwar aria-hidden={true} className={classes.tickLabel} > - {yTickFormat(yTickValue)} + {aTickLabels[aIndex]} ); })} ); - }, [xTickValues, yTickValues, xScale, yScale, outerRadius, xTickFormat, yTickFormat, classes]); + }, [rTickValues, aTickValues, rScale, aScale, outerRadius, classes]); const getActiveLegends = React.useCallback(() => { return selectedLegends.length > 0 ? selectedLegends : hoveredLegend ? [hoveredLegend] : []; @@ -264,10 +292,10 @@ export const PolarChart: React.FunctionComponent = React.forwar return areaData.map((series, seriesIndex) => { const radialArea = d3AreaRadial() - .angle(d => yScale(d.theta as any)!) + .angle(d => aScale(d.theta)) .innerRadius(innerRadius) - .outerRadius(d => xScale(d.r as any)!) - .curve(getCurveFactory(series.lineOptions?.curve)); + .outerRadius(d => rScale(d.r as any)!) + .curve(d3CurveLinearClosed); const shouldHighlight = legendHighlighted(series.legend); return ( @@ -294,16 +322,16 @@ export const PolarChart: React.FunctionComponent = React.forwar ); }); - }, [chartData, innerRadius, xScale, yScale, legendHighlighted]); + }, [chartData, innerRadius, rScale, aScale, legendHighlighted]); const renderRadialLines = React.useCallback(() => { const lineData = chartData.filter(series => series.type === 'linepolar'); return lineData.map((series, seriesIndex) => { const radialLine = d3LineRadial() - .angle(d => yScale(d.theta as any)!) - .radius(d => xScale(d.r as any)!) - .curve(getCurveFactory(series.lineOptions?.curve)); + .angle(d => aScale(d.theta)) + .radius(d => rScale(d.r as any)!) + .curve(d3CurveLinearClosed); return ( = React.forwar ); }); - }, [chartData, xScale, yScale, legendHighlighted]); + }, [chartData, rScale, aScale, legendHighlighted]); const [minMarkerSize, maxMarkerSize] = React.useMemo( () => d3Extent(chartData.flatMap(series => series.data.map(point => point.markerSize as number))), @@ -344,10 +372,10 @@ export const PolarChart: React.FunctionComponent = React.forwar ) => { setPopoverTarget(event.currentTarget); setPopoverOpen(legendHighlighted(legend)); - setPopoverXValue(point.radialAxisCalloutData ?? xTickFormat(point.r)); + setPopoverXValue(point.radialAxisCalloutData ?? point.r); setPopoverLegend(legend); setPopoverColor(point.color!); - setPopoverYValue(point.angularAxisCalloutData ?? yTickFormat(point.theta)); + setPopoverYValue(point.angularAxisCalloutData ?? point.theta); setActivePoint(pointId); }, [], @@ -371,7 +399,7 @@ export const PolarChart: React.FunctionComponent = React.forwar } data points.`} > {series.data.map((point, pointIndex) => { - const [x, y] = d3PointRadial(yScale(point.theta as any)!, xScale(point.r as any)!); + const [x, y] = d3PointRadial(aScale(point.theta), rScale(point.r as any)!); const id = `${seriesIndex}-${pointIndex}`; const isActive = activePoint === id; let radius = isActive ? 6 : MIN_PIXEL; @@ -381,9 +409,9 @@ export const PolarChart: React.FunctionComponent = React.forwar ((point.markerSize - minMarkerSize!) / (maxMarkerSize! - minMarkerSize!)) * (MAX_PIXEL - MIN_PIXEL); } - const xValue = point.radialAxisCalloutData || xTickFormat(point.r); + const xValue = point.radialAxisCalloutData || point.r; const legend = series.legend; - const yValue = point.angularAxisCalloutData || yTickFormat(point.theta); + const yValue = point.angularAxisCalloutData || point.theta; const ariaLabel = point.callOutAccessibilityData?.ariaLabel || `${xValue}. ${legend}, ${yValue}.`; return ( @@ -408,17 +436,7 @@ export const PolarChart: React.FunctionComponent = React.forwar ); }); }, - [ - legendHighlighted, - xScale, - yScale, - activePoint, - showPopover, - xTickFormat, - yTickFormat, - minMarkerSize, - maxMarkerSize, - ], + [legendHighlighted, rScale, aScale, activePoint, showPopover, minMarkerSize, maxMarkerSize], ); const onLegendSelectionChange = React.useCallback( diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts index bd12036517c1e3..c42e8ad5238a89 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts @@ -1,20 +1,102 @@ import { - CartesianChartProps, - CartesianChartStyleProps, - CartesianChartStyles, -} from '../CommonComponents/CartesianChart.types'; -import { AreaPolarSeries, LinePolarSeries, ScatterPolarSeries } from '../../types/DataPoint'; + AreaPolarSeries, + AxisCategoryOrder, + AxisProps, + AxisScaleType, + Chart, + LinePolarSeries, + Margins, + ScatterPolarSeries, +} from '../../types/DataPoint'; +import { LegendsProps } from '../Legends/Legends.types'; + +export type PolarAxisProps = AxisProps & { + /** + * + */ + tickValues?: number[] | Date[] | string[]; + + /** + * + */ + tickFormat?: string; + + /** + * + */ + tickCount?: number; + + /** + * + */ + title?: string; + + /** + * @default 'default' + */ + categoryOrder?: AxisCategoryOrder; + + /** + * @default 'default' + */ + scaleType?: AxisScaleType; + + /** + * + */ + rangeStart?: number | Date; + + /** + * + */ + rangeEnd?: number | Date; +}; /** * Polar Chart properties * {@docCategory PolarChart} */ -export interface PolarChartProps extends CartesianChartProps { +export interface PolarChartProps { /** * */ data: (AreaPolarSeries | LinePolarSeries | ScatterPolarSeries)[]; + /** + * + */ + width?: number; + + /** + * + */ + height?: number; + + /** + * + */ + margins?: Margins; + + /** + * @default false + */ + hideLegend?: boolean; + + /** + * @default false + */ + hideTooltip?: boolean; + + /* + * + */ + legendProps?: Partial; + + /** + * + */ + styles?: PolarChartStyles; + /** * */ @@ -23,25 +105,76 @@ export interface PolarChartProps extends CartesianChartProps { /** * */ - innerRadius?: number; + hole?: number; /** * @default 'circle' */ shape?: 'circle' | 'polygon'; + + /** + * + */ + direction?: 'clockwise' | 'counterclockwise'; + + /** + * + */ + radialAxis?: PolarAxisProps; + + /** + * + */ + angularAxis?: PolarAxisProps; + + /** + * Optional callback to access the Chart interface. Use this instead of ref for accessing + * the public methods and properties of the component. + */ + componentRef?: React.Ref; + + /** + * + */ + culture?: string; + + /** + * + */ + dateLocalizeOptions?: Intl.DateTimeFormatOptions; + + /** + * @default true + */ + useUTC?: boolean; } /** * Polar Chart style properties * {@docCategory PolarChart} */ -export interface PolarChartStyleProps extends CartesianChartStyleProps {} +export interface PolarChartStyleProps {} /** * Polar Chart styles * {@docCategory PolarChart} */ -export interface PolarChartStyles extends CartesianChartStyles { +export interface PolarChartStyles { + /** + * + */ + root?: string; + + /** + * + */ + chartWrapper?: string; + + /** + * + */ + chart?: string; + /** * */ diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts index 4aa8ec06c83253..07e2f940635de2 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts @@ -4,53 +4,130 @@ import { scaleLog as d3ScaleLog, scaleTime as d3ScaleTime, scaleUtc as d3ScaleUtc, + NumberValue, ScaleContinuousNumeric, ScaleTime, } from 'd3-scale'; -import { extent as d3Extent } from 'd3-array'; +import { extent as d3Extent, range as d3Range } from 'd3-array'; +import { format as d3Format } from 'd3-format'; import { AxisScaleType } from '../../types/DataPoint'; +import { + generateDateTicks, + generateNumericTicks, + getDateFormatLevel, + isValidDomainValue, +} from '../../utilities/utilities'; +import { + isInvalidValue, + formatToLocaleString, + getMultiLevelDateTimeFormatOptions, + formatDateToLocaleString, +} from '@fluentui/chart-utilities'; +import { timeFormat as d3TimeFormat, utcFormat as d3UtcFormat } from 'd3-time-format'; -export const createScale = ( +export const EPSILON = 1e-6; + +export const createRadialScale = ( scaleType: string, domain: (string | number | Date)[], range: number[], opts: { useUTC?: boolean; - niceBounds?: boolean; - innerPadding?: number; tickCount?: number; - hideTickOverlap?: boolean; tickValues?: (string | number | Date)[]; tickText?: string[]; - tickFormat?: () => string; + tickFormat?: string; + culture?: string; + tickStep?: number | string; + tick0?: number | Date; + dateLocalizeOptions?: Intl.DateTimeFormatOptions; } = {}, ) => { if (scaleType === 'category') { - return d3ScaleBand() + const scale = d3ScaleBand() .domain(domain as string[]) .range(range) - .paddingInner(opts.innerPadding || 0); + .paddingInner(1); + const tickValues = Array.isArray(opts.tickValues) ? (opts.tickValues as string[]) : (domain as string[]); + const tickFormat = (domainValue: string, index: number) => { + if (Array.isArray(opts.tickValues) && Array.isArray(opts.tickText) && !isInvalidValue(opts.tickText[index])) { + return opts.tickText[index]; + } + return domainValue; + }; + return { scale, tickValues, tickLabels: tickValues.map(tickFormat) }; } let scale: ScaleContinuousNumeric | ScaleTime; - switch (scaleType) { - case 'log': - scale = d3ScaleLog(); - break; - case 'date': - scale = opts.useUTC ? d3ScaleUtc() : d3ScaleTime(); - break; - default: - scale = d3ScaleLinear(); + if (scaleType === 'date') { + scale = opts.useUTC ? d3ScaleUtc() : d3ScaleTime(); + } else { + scale = scaleType === 'log' ? d3ScaleLog() : d3ScaleLinear(); } scale.domain(domain as (number | Date)[]); scale.range(range); - if (opts.niceBounds) { - scale.nice(); + scale.nice(); + + const tickCount = opts.tickCount ?? 3; + let tickFormat; + let customTickValues = Array.isArray(opts.tickValues) ? (opts.tickValues as (number | Date)[]) : undefined; + if (scaleType === 'date') { + let lowestFormatLevel = 100; + let highestFormatLevel = -1; + + (scale as ScaleTime).ticks().forEach((domainValue: Date) => { + const formatLevel = getDateFormatLevel(domainValue, opts.useUTC); + if (formatLevel > highestFormatLevel) { + highestFormatLevel = formatLevel; + } + if (formatLevel < lowestFormatLevel) { + lowestFormatLevel = formatLevel; + } + }); + const formatOptions = + opts.dateLocalizeOptions ?? getMultiLevelDateTimeFormatOptions(lowestFormatLevel, highestFormatLevel); + tickFormat = (domainValue: Date, index: number) => { + if (Array.isArray(opts.tickValues) && Array.isArray(opts.tickText) && !isInvalidValue(opts.tickText[index])) { + return opts.tickText[index]; + } + if (isInvalidValue(opts.culture) && typeof opts.tickFormat === 'string') { + if (opts.useUTC) { + return d3UtcFormat(opts.tickFormat)(domainValue); + } else { + return d3TimeFormat(opts.tickFormat)(domainValue); + } + } + return formatDateToLocaleString(domainValue, opts.culture, opts.useUTC, false, formatOptions); + }; + if (opts.tickStep) { + customTickValues = generateDateTicks(opts.tickStep, opts.tick0, scale.domain() as Date[], opts.useUTC); + } + } else { + const defaultTickFormat = (scale as ScaleContinuousNumeric).tickFormat(tickCount); + tickFormat = (domainValue: NumberValue, index: number) => { + if (Array.isArray(opts.tickValues) && Array.isArray(opts.tickText) && !isInvalidValue(opts.tickText[index])) { + return opts.tickText[index]; + } + if (typeof opts.tickFormat === 'string') { + return d3Format(opts.tickFormat)(domainValue); + } + const value = typeof domainValue === 'number' ? domainValue : domainValue.valueOf(); + return defaultTickFormat(value) === '' ? '' : (formatToLocaleString(value, opts.culture) as string); + }; + if (opts.tickStep) { + customTickValues = generateNumericTicks( + scaleType as AxisScaleType, + opts.tickStep, + opts.tick0, + scale.domain() as number[], + ); + } } + const tickValues = customTickValues ?? scale.ticks(tickCount); + const tickLabels = tickValues.map(tickFormat); - return scale; + return { scale, tickValues, tickLabels }; }; export const getScaleType = ( @@ -73,27 +150,89 @@ export const getScaleType = ( return scaleType; }; -export const getDomain = ( +export const getScaleDomain = ( scaleType: string, values: (string | number | Date)[], opts: { - start?: number | Date; - end?: number | Date; + rangeStart?: number | Date; + rangeEnd?: number | Date; } = {}, ) => { if (scaleType === 'category') { return Array.from(new Set(values)); } - let [min, max] = d3Extent(values as (number | Date)[]); - if (typeof opts.start !== 'undefined') { - min = opts.start; + let [min, max] = d3Extent(values.filter(v => isValidDomainValue(v, scaleType as AxisScaleType)) as (number | Date)[]); + if (scaleType === 'linear') { + [min, max] = d3Extent([min, max, 0] as number[]); } - if (typeof opts.end !== 'undefined') { - max = opts.end; + if (!isInvalidValue(opts.rangeStart)) { + min = opts.rangeStart; + } + if (!isInvalidValue(opts.rangeEnd)) { + max = opts.rangeEnd; + } + + if (isInvalidValue(min) || isInvalidValue(max)) { + return []; + } + return [min!, max!]; +}; + +export const degToRad = (deg: number) => (deg * Math.PI) / 180; + +export const createAngularScale = ( + scaleType: string, + domain: (string | number | Date)[], + // range: number[], + opts: { + tickCount?: number; + tickValues?: (string | number | Date)[]; + tickText?: string[]; + tickFormat?: string; + culture?: string; + tickStep?: number | string; + tick0?: number | Date; + } = {}, +): { scale: (v: string | number) => number; tickValues: (string | number)[]; tickLabels: string[] } => { + if (scaleType === 'category') { + const mp = {}; + domain.forEach((d, i) => { + mp[d] = i; + }); + const x = 360 / domain.length; + const tickValues = Array.isArray(opts.tickValues) ? (opts.tickValues as string[]) : (domain as string[]); + const tickFormat = (domainValue: string, index: number) => { + if (Array.isArray(opts.tickValues) && Array.isArray(opts.tickText) && !isInvalidValue(opts.tickText[index])) { + return opts.tickText[index]; + } + return domainValue; + }; + return { + scale: (v: string) => degToRad(mp[v] * x), + tickValues, + tickLabels: tickValues.map(tickFormat), + }; } - if (typeof min !== 'undefined' && typeof max !== 'undefined') { - return [min, max]; + + let customTickValues = Array.isArray(opts.tickValues) ? (opts.tickValues as number[]) : undefined; + const tickFormat = (domainValue: number, index: number) => { + if (Array.isArray(opts.tickValues) && Array.isArray(opts.tickText) && !isInvalidValue(opts.tickText[index])) { + return opts.tickText[index]; + } + if (typeof opts.tickFormat === 'string') { + return d3Format(opts.tickFormat)(domainValue); + } + return formatToLocaleString(domainValue, opts.culture) as string; + }; + if (opts.tickStep) { + customTickValues = generateNumericTicks(scaleType as AxisScaleType, opts.tickStep, opts.tick0, [0, 360 - EPSILON]); } - return []; + const tickValues = customTickValues ?? d3Range(0, 360, 360 / (opts.tickCount ?? 8)); + + return { + scale: (v: number) => degToRad(v), + tickValues, + tickLabels: tickValues.map(tickFormat), + }; }; diff --git a/packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts b/packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts index d39cff6e4c90a2..bb248bd3bb9f94 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts +++ b/packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts @@ -7,23 +7,9 @@ import { tokens, typographyStyles } from '@fluentui/react-theme'; * @internal */ export const polarChartClassNames: SlotClassNames = { - root: '', - xAxis: '', - yAxis: '', - legendContainer: '', - hover: '', - descriptionMessage: '', - tooltip: '', - axisTitle: '', - chartTitle: '', - opacityChangeOnHover: '', - shapeStyles: '', - chartWrapper: '', - svgTooltip: '', - chart: '', - axisAnnotation: '', - plotContainer: '', - annotationLayer: '', + root: 'fui-polar__root', + chartWrapper: 'fui-polar__chartWrapper', + chart: 'fui-polar__chart', gridLineInner: 'fui-polar__gridLineInner', gridLineOuter: 'fui-polar__gridLineOuter', tickLabel: 'fui-polar__tickLabel', diff --git a/packages/charts/react-charts/library/src/types/DataPoint.ts b/packages/charts/react-charts/library/src/types/DataPoint.ts index 6d2b9cfd893fbf..5c2bf4555ea8f7 100644 --- a/packages/charts/react-charts/library/src/types/DataPoint.ts +++ b/packages/charts/react-charts/library/src/types/DataPoint.ts @@ -1258,7 +1258,7 @@ export interface PolarDataPoint { /** * */ - r: number; + r: string | number | Date; /** * diff --git a/packages/charts/react-charts/library/src/utilities/utilities.ts b/packages/charts/react-charts/library/src/utilities/utilities.ts index f100b67362fc4f..d608e8ddbcc55a 100644 --- a/packages/charts/react-charts/library/src/utilities/utilities.ts +++ b/packages/charts/react-charts/library/src/utilities/utilities.ts @@ -335,7 +335,7 @@ export function createNumericXAxis( * @param useUTC * @returns */ -function getMultiLevelD3DateFormatter( +export function getMultiLevelD3DateFormatter( startLevel: number, endLevel: number, locale?: d3TimeLocaleObject, @@ -2358,7 +2358,7 @@ export const generateMonthlyTicks = ( return ticks; }; -const generateNumericTicks = ( +export const generateNumericTicks = ( scaleType: AxisScaleType | undefined, tickStep: string | number | undefined, tick0: number | Date | undefined, @@ -2391,7 +2391,7 @@ const generateNumericTicks = ( } }; -const generateDateTicks = ( +export const generateDateTicks = ( tickStep: string | number | undefined, tick0: number | Date | undefined, scaleDomain: Date[], From 4381e838eb191d5f6decc599d9701a2c82303aab Mon Sep 17 00:00:00 2001 From: Kumar Kshitij Date: Wed, 7 Jan 2026 02:41:24 +0530 Subject: [PATCH 06/12] update example --- .../DeclarativeChartDefault.stories.tsx | 117 ++++++++++-------- 1 file changed, 67 insertions(+), 50 deletions(-) diff --git a/packages/charts/react-charts/stories/src/DeclarativeChart/DeclarativeChartDefault.stories.tsx b/packages/charts/react-charts/stories/src/DeclarativeChart/DeclarativeChartDefault.stories.tsx index ad22d6c2da5fa6..104d0455107f3c 100644 --- a/packages/charts/react-charts/stories/src/DeclarativeChart/DeclarativeChartDefault.stories.tsx +++ b/packages/charts/react-charts/stories/src/DeclarativeChart/DeclarativeChartDefault.stories.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import type { JSXElement } from '@fluentui/react-components'; import { DeclarativeChart, IDeclarativeChart, Schema } from '@fluentui/react-charts'; import { + Button, Dropdown, Field, Input, @@ -158,18 +159,7 @@ const DEFAULT_SCHEMAS = [ const dropdownStyles = { width: 200 }; const inputStyles = { maxWidth: 300 }; -const cachedFetch = (url: string) => { - const cachedData = localStorage.getItem(url); - if (cachedData) { - return Promise.resolve(JSON.parse(cachedData)); - } - return fetch(url) - .then(response => response.json()) - .then(data => { - localStorage.setItem(url, JSON.stringify(data)); - return data; - }); -}; +type LoadingState = 'initial' | 'loading' | 'loaded'; export const DeclarativeChartBasicExample = (): JSXElement => { const declarativeChartRef = React.useRef(null); @@ -185,7 +175,8 @@ export const DeclarativeChartBasicExample = (): JSXElement => { const [fluentDataVizColorPalette, setFluentDataVizColorPalette] = React.useState('default'); const [showMore, setShowMore] = React.useState(false); - const [isLoading, setLoading] = React.useState(false); + const [loadingState, setLoadingState] = React.useState('initial'); + const [isLoadMoreDisabled, setLoadMoreDisabled] = React.useState(false); React.useEffect(() => { doc?.addEventListener('contextmenu', e => { @@ -193,29 +184,35 @@ export const DeclarativeChartBasicExample = (): JSXElement => { }); }, [doc]); - React.useEffect(() => { - const loadSchemas = async () => { - setLoading(true); - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const _schemas: { key: string; schema: any }[] = []; - for (let i = 1; i <= 80; i++) { - try { - const filename = `data_${('00' + i).slice(-3)}`; - const schema = await cachedFetch( - `https://raw.githubusercontent.com/microsoft/fluentui-charting-contrib/refs/heads/main/apps/plotly_examples/src/data/${filename}.json`, - ); - _schemas.push({ key: filename, schema }); - } catch (error) { - // Nothing to do here - } - } - loadedSchemas.current = _schemas; - setLoading(false); - }; - - loadSchemas(); + const loadSchemas = React.useCallback(async (_loadingState: LoadingState = 'loading') => { + setLoadingState(_loadingState); + let disableLoadMore = false; + const offset = loadedSchemas.current.length; + const promises = Array.from({ length: 100 }, (_, index) => { + const id = offset + index + 1; + const filename = `data_${id < 100 ? ('00' + id).slice(-3) : id}`; + return fetch( + `https://raw.githubusercontent.com/microsoft/fluentui-charting-contrib/refs/heads/main/apps/plotly_examples/src/data/${filename}.json`, + ) + .then(response => { + if (response.status === 404) { + disableLoadMore = true; + return null; + } + return response.json(); + }) + .then(schema => ({ key: filename, schema })) + .catch(() => ({ key: filename, schema: {} })); + }); + loadedSchemas.current.push(...(await Promise.all(promises)).filter(item => item.schema !== null)); + setLoadingState('loaded'); + setLoadMoreDisabled(disableLoadMore); }, []); + React.useEffect(() => { + loadSchemas('initial'); + }, [loadSchemas]); + const getSchemaByKey = React.useCallback( /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ (key: string): any => { @@ -226,23 +223,32 @@ export const DeclarativeChartBasicExample = (): JSXElement => { ); React.useEffect(() => { - if (showMore && (isLoading || loadedSchemas.current.length === 0)) { - setOptions([]); - setSelectedOptions([]); - setDropdownValue(''); - setSelectedLegends(''); + if (showMore) { + if (loadingState === 'initial' || loadedSchemas.current.length === 0) { + setOptions([]); + setSelectedOptions([]); + setDropdownValue(''); + setSelectedLegends(''); + } else if (loadingState === 'loaded') { + const _options = loadedSchemas.current.map(schema => ({ key: schema.key, text: schema.key })); + setOptions(_options); + if (!dropdownValue.includes('data_')) { + setSelectedOptions([_options[0].key]); + setDropdownValue(_options[0].text); + const selectedPlotlySchema = getSchemaByKey(_options[0].key); + const { selectedLegends: _selectedLegends } = selectedPlotlySchema; + setSelectedLegends(_selectedLegends ? JSON.stringify(_selectedLegends) : ''); + } + } } else { - const _options = showMore - ? loadedSchemas.current.map(schema => ({ key: schema.key, text: schema.key })) - : DEFAULT_OPTIONS; - setOptions(_options); - setSelectedOptions([_options[0].key]); - setDropdownValue(_options[0].text); - const selectedPlotlySchema = getSchemaByKey(_options[0].key); + setOptions(DEFAULT_OPTIONS); + setSelectedOptions([DEFAULT_OPTIONS[0].key]); + setDropdownValue(DEFAULT_OPTIONS[0].text); + const selectedPlotlySchema = getSchemaByKey(DEFAULT_OPTIONS[0].key); const { selectedLegends: _selectedLegends } = selectedPlotlySchema; setSelectedLegends(_selectedLegends ? JSON.stringify(_selectedLegends) : ''); } - }, [showMore, isLoading, getSchemaByKey]); + }, [showMore, loadingState, getSchemaByKey]); const onSwitchDataChange = React.useCallback((ev: React.ChangeEvent) => { setShowMore(ev.currentTarget.checked); @@ -293,7 +299,7 @@ export const DeclarativeChartBasicExample = (): JSXElement => { const renderDeclarativeChart = React.useCallback(() => { if (showMore) { - if (isLoading) { + if (loadingState === 'initial') { return ; } else if (loadedSchemas.current.length === 0) { return
More examples could not be loaded.
; @@ -325,7 +331,7 @@ export const DeclarativeChartBasicExample = (): JSXElement => { ); }, [ showMore, - isLoading, + loadingState, selectedOptions, selectedLegends, getSchemaByKey, @@ -336,7 +342,7 @@ export const DeclarativeChartBasicExample = (): JSXElement => { return (
-
+
{ ))} + {showMore && ( +
+ +
+ )} Date: Wed, 7 Jan 2026 13:41:51 +0530 Subject: [PATCH 07/12] add support for polar subplots --- .../src/PlotlySchemaConverter.ts | 6 +- .../DeclarativeChart/DeclarativeChart.tsx | 4 +- .../DeclarativeChart/PlotlySchemaAdapter.ts | 55 ++++++++++++------- .../src/components/PolarChart/PolarChart.tsx | 3 +- 4 files changed, 43 insertions(+), 25 deletions(-) diff --git a/packages/charts/chart-utilities/src/PlotlySchemaConverter.ts b/packages/charts/chart-utilities/src/PlotlySchemaConverter.ts index 8fc089177104e3..921eb923057711 100644 --- a/packages/charts/chart-utilities/src/PlotlySchemaConverter.ts +++ b/packages/charts/chart-utilities/src/PlotlySchemaConverter.ts @@ -422,9 +422,9 @@ const DATA_VALIDATORS_MAP: Record if (!isNumberArray((data as Partial).theta) && !isStringArray((data as Partial).theta)) { throw new Error(`${UNSUPPORTED_MSG_PREFIX} ${data.type}, theta values must be array of numbers or strings`); } - if (!isNumberArray((data as Partial).r)) { - throw new Error(`${UNSUPPORTED_MSG_PREFIX} ${data.type}, Non numeric r values`); - } + // if (!isNumberArray((data as Partial).r)) { + // throw new Error(`${UNSUPPORTED_MSG_PREFIX} ${data.type}, Non numeric r values`); + // } }, ], funnel: [data => validateSeriesData(data as Partial, false)], diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx index edfdaff73da17b..3d8f6945a99516 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx @@ -481,7 +481,9 @@ export const DeclarativeChart: React.FunctionComponent = traceKey = `${NON_PLOT_KEY_PREFIX}${nonCartesianTraceCount + 1}`; nonCartesianTraceCount++; } else { - traceKey = (trace as PlotData).xaxis ?? DEFAULT_XAXIS; + traceKey = ['scatterpolar'].includes(trace.type!) + ? (trace as { subplot?: string }).subplot ?? 'polar' + : (trace as PlotData).xaxis ?? DEFAULT_XAXIS; } if (!groupedTraces[traceKey]) { groupedTraces[traceKey] = []; diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts index 75648db90ccbee..441febb862f5e8 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts @@ -70,6 +70,7 @@ import type { AxisType, Shape, Annotations, + PolarLayout, } from '@fluentui/chart-utilities'; import { isArrayOrTypedArray, @@ -112,6 +113,10 @@ type DomainInterval = { end: number; }; +type ExtDomainInterval = DomainInterval & { + cellName: string; +}; + export type AxisProperties = { xAnnotation?: string; yAnnotation?: string; @@ -3455,8 +3460,8 @@ export const getGridProperties = ( isMultiPlot: boolean, validTracesInfo: TraceInfo[], ): GridProperties => { - const domainX: DomainInterval[] = []; - const domainY: DomainInterval[] = []; + const domainX: ExtDomainInterval[] = []; + const domainY: ExtDomainInterval[] = []; let cartesianDomains = 0; type AnnotationProps = { xAnnotation?: string; @@ -3482,9 +3487,10 @@ export const getGridProperties = ( throw new Error(`Invalid layout: xaxis ${index + 1} anchor should be y${anchorIndex + 1}`); } const xAxisLayout = layout[key as keyof typeof layout] as Partial; - const domainXInfo: DomainInterval = { + const domainXInfo: ExtDomainInterval = { start: xAxisLayout?.domain ? xAxisLayout.domain[0] : 0, end: xAxisLayout?.domain ? xAxisLayout.domain[1] : 1, + cellName: `x${domainX.length === 0 ? '' : domainX.length + 1}` as XAxisName, }; domainX.push(domainXInfo); } else if (key.startsWith('yaxis')) { @@ -3499,9 +3505,10 @@ export const getGridProperties = ( throw new Error(`Invalid layout: yaxis ${index + 1} anchor should be x${anchorIndex + 1}`); } const yAxisLayout = layout[key as keyof typeof layout] as Partial; - const domainYInfo: DomainInterval = { + const domainYInfo: ExtDomainInterval = { start: yAxisLayout?.domain ? yAxisLayout.domain[0] : 0, end: yAxisLayout?.domain ? yAxisLayout.domain[1] : 1, + cellName: `x${domainY.length === 0 ? '' : domainY.length + 1}` as XAxisName, }; domainY.push(domainYInfo); } @@ -3512,13 +3519,15 @@ export const getGridProperties = ( validTracesInfo.forEach((trace, index) => { if (isNonPlotType(trace.type)) { const series = schema?.data?.[index] as Partial | Partial; - const domainXInfo: DomainInterval = { + const domainXInfo: ExtDomainInterval = { start: series.domain?.x ? series.domain.x[0] : 0, end: series.domain?.x ? series.domain.x[1] : 1, + cellName: `${NON_PLOT_KEY_PREFIX}${domainX.length - cartesianDomains + 1}`, }; - const domainYInfo: DomainInterval = { + const domainYInfo: ExtDomainInterval = { start: series.domain?.y ? series.domain.y[0] : 0, end: series.domain?.y ? series.domain.y[1] : 1, + cellName: `${NON_PLOT_KEY_PREFIX}${domainY.length - cartesianDomains + 1}`, }; domainX.push(domainXInfo); domainY.push(domainYInfo); @@ -3526,6 +3535,23 @@ export const getGridProperties = ( }); if (layout !== undefined && layout !== null && Object.keys(layout).length > 0) { + Object.keys(layout ?? {}).forEach(key => { + if (key.startsWith('polar')) { + const polarLayout = layout[key as keyof typeof layout] as Partial; + const domainXInfo: ExtDomainInterval = { + start: polarLayout.domain?.x ? polarLayout.domain.x[0] : 0, + end: polarLayout.domain?.x ? polarLayout.domain.x[1] : 1, + cellName: key, + }; + const domainYInfo: ExtDomainInterval = { + start: polarLayout.domain?.y ? polarLayout.domain.y[0] : 0, + end: polarLayout.domain?.y ? polarLayout.domain.y[1] : 1, + cellName: key, + }; + domainX.push(domainXInfo); + domainY.push(domainYInfo); + } + }); layout.annotations?.forEach(annotation => { const xMatches = domainX.flatMap((interval, idx) => (annotation?.x as number) >= interval.start && (annotation?.x as number) <= interval.end ? [idx] : [], @@ -3551,7 +3577,7 @@ export const getGridProperties = ( } if (domainX.length > 0) { - const uniqueXIntervals = new Map(); + const uniqueXIntervals = new Map(); domainX.forEach(interval => { const key = `${interval.start}-${interval.end}`; if (!uniqueXIntervals.has(key)) { @@ -3565,11 +3591,6 @@ export const getGridProperties = ( templateColumns = `repeat(${sortedXStart.length}, 1fr)`; domainX.forEach((interval, index) => { - const cellName = - index >= cartesianDomains - ? `${NON_PLOT_KEY_PREFIX}${index - cartesianDomains + 1}` - : (`x${index === 0 ? '' : index + 1}` as XAxisName); - const columnIndex = sortedXStart.findIndex(start => start === interval.start); const columnNumber = columnIndex + 1; // Column numbers are 1-based @@ -3583,11 +3604,11 @@ export const getGridProperties = ( xDomain: interval, yDomain: { start: 0, end: 1 }, // Default yDomain for x-axis }; - gridLayout[cellName] = row; + gridLayout[interval.cellName] = row; }); } if (domainY.length > 0) { - const uniqueYIntervals = new Map(); + const uniqueYIntervals = new Map(); domainY.forEach(interval => { const key = `${interval.start}-${interval.end}`; if (!uniqueYIntervals.has(key)) { @@ -3602,17 +3623,13 @@ export const getGridProperties = ( templateRows = `repeat(${numberOfRows}, 1fr)`; domainY.forEach((interval, index) => { - const cellName = - index >= cartesianDomains - ? `${NON_PLOT_KEY_PREFIX}${index - cartesianDomains + 1}` - : (`x${index === 0 ? '' : index + 1}` as XAxisName); const rowIndex = sortedYStart.findIndex(start => start === interval.start); const rowNumber = numberOfRows - rowIndex; // Rows are 1-based and we need to reverse the order for CSS grid const annotationProps = annotations[index] as AnnotationProps; const yAnnotation = annotationProps?.yAnnotation; - const cell = gridLayout[cellName]; + const cell = gridLayout[interval.cellName]; if (cell !== undefined) { cell.row = rowNumber; diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx index 4e6b004e5a86a8..3f275d136c10bd 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx @@ -330,8 +330,7 @@ export const PolarChart: React.FunctionComponent = React.forwar return lineData.map((series, seriesIndex) => { const radialLine = d3LineRadial() .angle(d => aScale(d.theta)) - .radius(d => rScale(d.r as any)!) - .curve(d3CurveLinearClosed); + .radius(d => rScale(d.r as any)!); return ( Date: Thu, 8 Jan 2026 18:31:32 +0530 Subject: [PATCH 08/12] add direction support --- .../src/components/PolarChart/PolarChart.tsx | 23 ++++++++++++++----- .../components/PolarChart/PolarChart.types.ts | 2 +- .../components/PolarChart/PolarChart.utils.ts | 9 +++++--- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx index 3f275d136c10bd..74a1a5bf709045 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx @@ -152,11 +152,11 @@ export const PolarChart: React.FunctionComponent = React.forwar () => getScaleType(aValues, { scaleType: props.angularAxis?.scaleType, - supportsLog: true, + // supportsLog: true, }), [aValues, props.angularAxis?.scaleType], ); - const aDomain = React.useMemo(() => getScaleDomain(aType, aValues), [aType, aValues]); + const aDomain = React.useMemo(() => getScaleDomain(aType, aValues) as (string | number)[], [aType, aValues]); const { scale: aScale, tickValues: aTickValues, @@ -171,6 +171,7 @@ export const PolarChart: React.FunctionComponent = React.forwar culture: props.culture, tickStep: props.angularAxis?.tickStep, tick0: props.angularAxis?.tick0, + direction: props.direction, }), [aType, aDomain], ); @@ -230,12 +231,22 @@ export const PolarChart: React.FunctionComponent = React.forwar {rTickValues.map((r, rIndex) => { + const angle = props.direction === 'clockwise' ? 0 : Math.PI / 2; + const [pointX, pointY] = d3PointRadial(angle, rScale(r as any)!); + const multiplier = angle > EPSILON && angle - Math.PI < EPSILON ? 1 : -1; return ( EPSILON && angle - Math.PI / 2 < -EPSILON) || + (angle - Math.PI > EPSILON && angle - (3 * Math.PI) / 2 < -EPSILON) + ? 'start' + : 'end' + } dominantBaseline="middle" aria-hidden={true} className={classes.tickLabel} @@ -273,7 +284,7 @@ export const PolarChart: React.FunctionComponent = React.forwar ); - }, [rTickValues, aTickValues, rScale, aScale, outerRadius, classes]); + }, [rTickValues, aTickValues, rScale, aScale, outerRadius, classes, props.direction]); const getActiveLegends = React.useCallback(() => { return selectedLegends.length > 0 ? selectedLegends : hoveredLegend ? [hoveredLegend] : []; diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts index c42e8ad5238a89..4c1a3efa6a73c8 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts @@ -113,7 +113,7 @@ export interface PolarChartProps { shape?: 'circle' | 'polygon'; /** - * + * @default 'counterclockwise' */ direction?: 'clockwise' | 'counterclockwise'; diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts index 07e2f940635de2..3d167cce30320f 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts @@ -179,7 +179,9 @@ export const getScaleDomain = ( return [min!, max!]; }; -export const degToRad = (deg: number) => (deg * Math.PI) / 180; +const degToRad = (deg: number) => (deg * Math.PI) / 180; +const handleDir = (deg: number, direction: 'clockwise' | 'counterclockwise' = 'counterclockwise') => + (((direction === 'clockwise' ? deg : 450 - deg) % 360) + 360) % 360; export const createAngularScale = ( scaleType: string, @@ -193,6 +195,7 @@ export const createAngularScale = ( culture?: string; tickStep?: number | string; tick0?: number | Date; + direction?: 'clockwise' | 'counterclockwise'; } = {}, ): { scale: (v: string | number) => number; tickValues: (string | number)[]; tickLabels: string[] } => { if (scaleType === 'category') { @@ -209,7 +212,7 @@ export const createAngularScale = ( return domainValue; }; return { - scale: (v: string) => degToRad(mp[v] * x), + scale: (v: string) => degToRad(handleDir(mp[v] * x, opts.direction)), tickValues, tickLabels: tickValues.map(tickFormat), }; @@ -231,7 +234,7 @@ export const createAngularScale = ( const tickValues = customTickValues ?? d3Range(0, 360, 360 / (opts.tickCount ?? 8)); return { - scale: (v: number) => degToRad(v), + scale: (v: number) => degToRad(handleDir(v, opts.direction)), tickValues, tickLabels: tickValues.map(tickFormat), }; From e04e9a2053d56543c42352b5679f2d30fc4a1e1d Mon Sep 17 00:00:00 2001 From: Kumar Kshitij Date: Thu, 8 Jan 2026 19:46:57 +0530 Subject: [PATCH 09/12] add categoryorder support --- .../DeclarativeChart/PlotlySchemaAdapter.ts | 1 + .../src/components/PolarChart/PolarChart.tsx | 52 ++++++++++++++++--- .../components/PolarChart/PolarChart.utils.ts | 6 +-- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts index 441febb862f5e8..7729f8e0d3e715 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts @@ -4131,6 +4131,7 @@ const getsomething = (data: Data[], layout: Partial | undefined) => { title: '', scaleType: '', }; + props.direction = layout?.[subplotId]?.[m.theta.toLowerCase()]?.direction; }); return props; diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx index 74a1a5bf709045..4c59c3d3590c22 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx @@ -15,7 +15,7 @@ import { tokens } from '@fluentui/react-theme'; import { Legend, Legends } from '../Legends/index'; import { createRadialScale, getScaleDomain, getScaleType, EPSILON, createAngularScale } from './PolarChart.utils'; import { ChartPopover } from '../CommonComponents/ChartPopover'; -import { getColorFromToken, getNextColor } from '../../utilities/index'; +import { getColorFromToken, getNextColor, sortAxisCategories } from '../../utilities/index'; import { extent as d3Extent } from 'd3-array'; const DEFAULT_LEGEND_HEIGHT = 32; @@ -107,6 +107,34 @@ export const PolarChart: React.FunctionComponent = React.forwar }); }, [props.data]); + const mapCategoryToValues = React.useCallback( + (xyz?: boolean) => { + const categoryToValues: Record = {}; + chartData.forEach(series => { + series.data.forEach(point => { + const category = (xyz ? point.theta : point.r) as string; + if (!categoryToValues[category]) { + categoryToValues[category] = []; + } + const value = xyz ? point.r : point.theta; + if (typeof value === 'number') { + categoryToValues[category].push(value); + } + }); + }); + return categoryToValues; + }, + [chartData], + ); + + const getOrderedRValues = React.useCallback(() => { + return sortAxisCategories(mapCategoryToValues(), props.radialAxis?.categoryOrder); + }, [mapCategoryToValues, props.radialAxis?.categoryOrder]); + + const getOrderedAValues = React.useCallback(() => { + return sortAxisCategories(mapCategoryToValues(true), props.angularAxis?.categoryOrder); + }, [mapCategoryToValues, props.angularAxis?.categoryOrder]); + const rValues = React.useMemo(() => chartData.flatMap(series => series.data.map(point => point.r)), [chartData]); const rScaleType = React.useMemo( () => @@ -118,11 +146,13 @@ export const PolarChart: React.FunctionComponent = React.forwar ); const rScaleDomain = React.useMemo( () => - getScaleDomain(rScaleType, rValues, { - rangeStart: props.radialAxis?.rangeStart, - rangeEnd: props.radialAxis?.rangeEnd, - }), - [rScaleType, rValues, props.radialAxis?.rangeStart, props.radialAxis?.rangeEnd], + rScaleType === 'category' + ? getOrderedRValues() + : getScaleDomain(rScaleType, rValues as (number | Date)[], { + rangeStart: props.radialAxis?.rangeStart, + rangeEnd: props.radialAxis?.rangeEnd, + }), + [getOrderedRValues, rScaleType, rValues, props.radialAxis?.rangeStart, props.radialAxis?.rangeEnd], ); const { scale: rScale, @@ -156,7 +186,10 @@ export const PolarChart: React.FunctionComponent = React.forwar }), [aValues, props.angularAxis?.scaleType], ); - const aDomain = React.useMemo(() => getScaleDomain(aType, aValues) as (string | number)[], [aType, aValues]); + const aDomain = React.useMemo( + () => (aType === 'category' ? getOrderedAValues() : (getScaleDomain(aType, aValues as number[]) as number[])), + [getOrderedAValues, aType, aValues], + ); const { scale: aScale, tickValues: aTickValues, @@ -233,6 +266,7 @@ export const PolarChart: React.FunctionComponent = React.forwar {rTickValues.map((r, rIndex) => { const angle = props.direction === 'clockwise' ? 0 : Math.PI / 2; const [pointX, pointY] = d3PointRadial(angle, rScale(r as any)!); + // (0, pi] const multiplier = angle > EPSILON && angle - Math.PI < EPSILON ? 1 : -1; return ( = React.forwar x={pointX + LABEL_OFFSET * Math.cos(angle) * multiplier} y={pointY + LABEL_OFFSET * Math.sin(angle) * multiplier} textAnchor={ + // pi/2 or 3pi/2 Math.abs(angle - Math.PI / 2) < EPSILON || Math.abs(angle - (3 * Math.PI) / 2) < EPSILON ? 'middle' - : (angle > EPSILON && angle - Math.PI / 2 < -EPSILON) || + : // (0, pi/2) or (pi, 3pi/2) + (angle > EPSILON && angle - Math.PI / 2 < -EPSILON) || (angle - Math.PI > EPSILON && angle - (3 * Math.PI) / 2 < -EPSILON) ? 'start' : 'end' diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts index 3d167cce30320f..6a905ee150e747 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts @@ -152,16 +152,12 @@ export const getScaleType = ( export const getScaleDomain = ( scaleType: string, - values: (string | number | Date)[], + values: (number | Date)[], opts: { rangeStart?: number | Date; rangeEnd?: number | Date; } = {}, ) => { - if (scaleType === 'category') { - return Array.from(new Set(values)); - } - let [min, max] = d3Extent(values.filter(v => isValidDomainValue(v, scaleType as AxisScaleType)) as (number | Date)[]); if (scaleType === 'linear') { [min, max] = d3Extent([min, max, 0] as number[]); From 88116f61f755718d3f61fc2fe0373eb412b39069 Mon Sep 17 00:00:00 2001 From: Kumar Kshitij Date: Fri, 9 Jan 2026 00:47:33 +0530 Subject: [PATCH 10/12] render line separately for areapolar series --- .../src/components/PolarChart/PolarChart.tsx | 227 ++++++++---------- 1 file changed, 105 insertions(+), 122 deletions(-) diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx index 4c59c3d3590c22..171c811a985d41 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx @@ -10,7 +10,7 @@ import { lineRadial as d3LineRadial, curveLinearClosed as d3CurveLinearClosed, } from 'd3-shape'; -import { PolarDataPoint } from '../../types/DataPoint'; +import { AreaPolarSeries, LinePolarSeries, PolarDataPoint, ScatterPolarSeries } from '../../types/DataPoint'; import { tokens } from '@fluentui/react-theme'; import { Legend, Legends } from '../Legends/index'; import { createRadialScale, getScaleDomain, getScaleType, EPSILON, createAngularScale } from './PolarChart.utils'; @@ -87,24 +87,29 @@ export const PolarChart: React.FunctionComponent = React.forwar const chartData = React.useMemo(() => { legendColorMap.current = {}; let colorIndex = 0; - - return props.data.map(series => { - const seriesColor = series.color ? getColorFromToken(series.color) : getNextColor(colorIndex++, 0); - if (!(series.legend in legendColorMap.current)) { - legendColorMap.current[series.legend] = seriesColor; - } - - return { - ...series, - color: seriesColor, - data: series.data.map(point => { - return { - ...point, - color: point.color ? getColorFromToken(point.color) : seriesColor, - }; - }), - }; - }); + const order = ['areapolar', 'linepolar', 'scatterpolar']; + + return props.data + .map(series => { + const seriesColor = series.color ? getColorFromToken(series.color) : getNextColor(colorIndex++, 0); + if (!(series.legend in legendColorMap.current)) { + legendColorMap.current[series.legend] = seriesColor; + } + + return { + ...series, + color: seriesColor, + data: series.data.map(point => { + return { + ...point, + color: point.color ? getColorFromToken(point.color) : seriesColor, + }; + }), + }; + }) + .sort((a, b) => { + return order.indexOf(a.type) - order.indexOf(b.type); + }); }, [props.data]); const mapCategoryToValues = React.useCallback( @@ -334,10 +339,8 @@ export const PolarChart: React.FunctionComponent = React.forwar [getActiveLegends], ); - const renderRadialAreas = React.useCallback(() => { - const areaData = chartData.filter(series => series.type === 'areapolar'); - - return areaData.map((series, seriesIndex) => { + const renderRadialArea = React.useCallback( + (series: AreaPolarSeries) => { const radialArea = d3AreaRadial() .angle(d => aScale(d.theta)) .innerRadius(innerRadius) @@ -346,63 +349,39 @@ export const PolarChart: React.FunctionComponent = React.forwar const shouldHighlight = legendHighlighted(series.legend); return ( - - - {renderRadialPoints([series])} - + ); - }); - }, [chartData, innerRadius, rScale, aScale, legendHighlighted]); - - const renderRadialLines = React.useCallback(() => { - const lineData = chartData.filter(series => series.type === 'linepolar'); + }, + [innerRadius, rScale, aScale, legendHighlighted], + ); - return lineData.map((series, seriesIndex) => { + const renderRadialLine = React.useCallback( + (series: AreaPolarSeries | LinePolarSeries) => { const radialLine = d3LineRadial() .angle(d => aScale(d.theta)) .radius(d => rScale(d.r as any)!); return ( - - - {renderRadialPoints([series])} - + ); - }); - }, [chartData, rScale, aScale, legendHighlighted]); + }, + [rScale, aScale, legendHighlighted], + ); const [minMarkerSize, maxMarkerSize] = React.useMemo( () => d3Extent(chartData.flatMap(series => series.data.map(point => point.markerSize as number))), @@ -433,54 +412,46 @@ export const PolarChart: React.FunctionComponent = React.forwar }, []); const renderRadialPoints = React.useCallback( - (scatterData: typeof chartData) => { - return scatterData.map((series, seriesIndex) => { - const shouldHighlight = legendHighlighted(series.legend); - return ( - - {series.data.map((point, pointIndex) => { - const [x, y] = d3PointRadial(aScale(point.theta), rScale(point.r as any)!); - const id = `${seriesIndex}-${pointIndex}`; - const isActive = activePoint === id; - let radius = isActive ? 6 : MIN_PIXEL; - if (typeof point.markerSize !== 'undefined' && minMarkerSize !== maxMarkerSize) { - radius = - MIN_PIXEL + - ((point.markerSize - minMarkerSize!) / (maxMarkerSize! - minMarkerSize!)) * (MAX_PIXEL - MIN_PIXEL); - } - - const xValue = point.radialAxisCalloutData || point.r; - const legend = series.legend; - const yValue = point.angularAxisCalloutData || point.theta; - const ariaLabel = point.callOutAccessibilityData?.ariaLabel || `${xValue}. ${legend}, ${yValue}.`; + (series: AreaPolarSeries | LinePolarSeries | ScatterPolarSeries, seriesIndex: number) => { + const shouldHighlight = legendHighlighted(series.legend); + return ( + + {series.data.map((point, pointIndex) => { + const [x, y] = d3PointRadial(aScale(point.theta), rScale(point.r as any)!); + const id = `${seriesIndex}-${pointIndex}`; + const isActive = activePoint === id; + let radius = isActive ? 6 : MIN_PIXEL; + if (typeof point.markerSize !== 'undefined' && minMarkerSize !== maxMarkerSize) { + radius = + MIN_PIXEL + + ((point.markerSize - minMarkerSize!) / (maxMarkerSize! - minMarkerSize!)) * (MAX_PIXEL - MIN_PIXEL); + } - return ( - showPopover(e, point, id, series.legend)} - onFocus={e => showPopover(e, point, id, series.legend)} - role="img" - aria-label={ariaLabel} - /> - ); - })} - - ); - }); + const xValue = point.radialAxisCalloutData || point.r; + const legend = series.legend; + const yValue = point.angularAxisCalloutData || point.theta; + const ariaLabel = point.callOutAccessibilityData?.ariaLabel || `${xValue}. ${legend}, ${yValue}.`; + + return ( + showPopover(e, point, id, series.legend)} + onFocus={e => showPopover(e, point, id, series.legend)} + role="img" + aria-label={ariaLabel} + /> + ); + })} + + ); }, [legendHighlighted, rScale, aScale, activePoint, showPopover, minMarkerSize, maxMarkerSize], ); @@ -545,9 +516,21 @@ export const PolarChart: React.FunctionComponent = React.forwar > {renderPolarGrid()} - {renderRadialAreas()} - {renderRadialLines()} - {renderRadialPoints(chartData.filter(series => series.type === 'scatterpolar'))} + {chartData.map((series, seriesIndex) => { + return ( + + {series.type === 'areapolar' && renderRadialArea(series)} + {(series.type === 'areapolar' || series.type === 'linepolar') && renderRadialLine(series)} + {renderRadialPoints(series, seriesIndex)} + + ); + })} {renderPolarTicks()} From ec06c376f94de84287f8570c1d9ecece9976ef8b Mon Sep 17 00:00:00 2001 From: Kumar Kshitij Date: Fri, 9 Jan 2026 11:46:58 +0530 Subject: [PATCH 11/12] render radial axis tick lines --- .../src/components/PolarChart/PolarChart.tsx | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx index 171c811a985d41..fd5e462b3ac3a5 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx @@ -21,7 +21,8 @@ import { extent as d3Extent } from 'd3-array'; const DEFAULT_LEGEND_HEIGHT = 32; const LABEL_WIDTH = 36; const LABEL_HEIGHT = 16; -const LABEL_OFFSET = 8; +const LABEL_OFFSET = 10; +const TICK_SIZE = 6; const MIN_PIXEL = 4; const MAX_PIXEL = 16; @@ -265,35 +266,47 @@ export const PolarChart: React.FunctionComponent = React.forwar }, [innerRadius, outerRadius, rScaleDomain, rTickValues, aTickValues, rScale, aScale, props.shape, classes]); const renderPolarTicks = React.useCallback(() => { + const radialAxisAngle = props.direction === 'clockwise' ? 0 : Math.PI / 2; + const radialPoint1 = d3PointRadial(radialAxisAngle, innerRadius); + const radialPoint2 = d3PointRadial(radialAxisAngle, outerRadius); + return ( + {rTickValues.map((r, rIndex) => { - const angle = props.direction === 'clockwise' ? 0 : Math.PI / 2; - const [pointX, pointY] = d3PointRadial(angle, rScale(r as any)!); + const [pointX, pointY] = d3PointRadial(radialAxisAngle, rScale(r as any)!); // (0, pi] - const multiplier = angle > EPSILON && angle - Math.PI < EPSILON ? 1 : -1; + const multiplier = radialAxisAngle > EPSILON && radialAxisAngle - Math.PI < EPSILON ? 1 : -1; return ( - EPSILON && angle - Math.PI / 2 < -EPSILON) || - (angle - Math.PI > EPSILON && angle - (3 * Math.PI) / 2 < -EPSILON) - ? 'start' - : 'end' - } - dominantBaseline="middle" - aria-hidden={true} - className={classes.tickLabel} - > - {rTickLabels[rIndex]} - + + + EPSILON && radialAxisAngle - Math.PI / 2 < -EPSILON) || + (radialAxisAngle - Math.PI > EPSILON && radialAxisAngle - (3 * Math.PI) / 2 < -EPSILON) + ? 'start' + : 'end' + } + dominantBaseline="middle" + aria-hidden={true} + className={classes.tickLabel} + > + {rTickLabels[rIndex]} + + ); })} From 622f10ef435c328d2672c7fa7fcdfff158789fd6 Mon Sep 17 00:00:00 2001 From: Kumar Kshitij Date: Fri, 9 Jan 2026 16:25:21 +0530 Subject: [PATCH 12/12] fix bugs --- .../DeclarativeChart/PlotlySchemaAdapter.ts | 16 +++++++++---- .../src/components/PolarChart/PolarChart.tsx | 24 ++++++++++--------- .../components/PolarChart/PolarChart.utils.ts | 2 +- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts index 7729f8e0d3e715..0cfef478d75d73 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts @@ -4124,14 +4124,22 @@ const getsomething = (data: Data[], layout: Partial | undefined) => { }); const subplotId = (data[0] as { subplot?: string })?.subplot || 'polar'; + const polarLayout = layout?.[subplotId]; + const axType = getAxisType2(values, polarLayout?.[propName.toLowerCase()]); props[propName] = { - categoryOrder: getAxisCategoryOrderProps2(values, layout?.[subplotId]?.[propName.toLowerCase()]), - ...getAxisTickProps2(values, layout?.[subplotId]?.[propName.toLowerCase()]), + categoryOrder: getAxisCategoryOrderProps2(values, polarLayout?.[propName.toLowerCase()]), + ...getAxisTickProps2(values, polarLayout?.[propName.toLowerCase()]), tickFormat: '', title: '', - scaleType: '', + scaleType: axType === 'log' ? 'log' : 'default', + rangeStart: isArrayOrTypedArray(polarLayout?.[propName.toLowerCase()]?.range) + ? polarLayout[propName.toLowerCase()].range[0] + : undefined, + rangeEnd: isArrayOrTypedArray(polarLayout?.[propName.toLowerCase()]?.range) + ? polarLayout[propName.toLowerCase()].range[1] + : undefined, }; - props.direction = layout?.[subplotId]?.[m.theta.toLowerCase()]?.direction; + props.direction = polarLayout?.[m.theta.toLowerCase()]?.direction; }); return props; diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx index fd5e462b3ac3a5..f8e1bc9e871667 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx @@ -15,7 +15,7 @@ import { tokens } from '@fluentui/react-theme'; import { Legend, Legends } from '../Legends/index'; import { createRadialScale, getScaleDomain, getScaleType, EPSILON, createAngularScale } from './PolarChart.utils'; import { ChartPopover } from '../CommonComponents/ChartPopover'; -import { getColorFromToken, getNextColor, sortAxisCategories } from '../../utilities/index'; +import { getColorFromToken, getCurveFactory, getNextColor, sortAxisCategories } from '../../utilities/index'; import { extent as d3Extent } from 'd3-array'; const DEFAULT_LEGEND_HEIGHT = 32; @@ -23,7 +23,7 @@ const LABEL_WIDTH = 36; const LABEL_HEIGHT = 16; const LABEL_OFFSET = 10; const TICK_SIZE = 6; -const MIN_PIXEL = 4; +const MIN_PIXEL = 2; const MAX_PIXEL = 16; export const PolarChart: React.FunctionComponent = React.forwardRef( @@ -219,12 +219,13 @@ export const PolarChart: React.FunctionComponent = React.forwar const renderPolarGrid = React.useCallback(() => { const extRTickValues = []; - if (innerRadius > 0 && rScaleDomain[0] !== rTickValues[0]) { - extRTickValues.push(rScaleDomain[0]); + const rDomain = rScale.domain(); + if (innerRadius > 0 && rDomain[0] !== rTickValues[0]) { + extRTickValues.push(rDomain[0]); } extRTickValues.push(...rTickValues); - if (rScaleDomain[rScaleDomain.length - 1] !== rTickValues[rTickValues.length - 1]) { - extRTickValues.push(rScaleDomain[rScaleDomain.length - 1]); + if (rDomain[rDomain.length - 1] !== rTickValues[rTickValues.length - 1]) { + extRTickValues.push(rDomain[rDomain.length - 1]); } return ( @@ -266,7 +267,7 @@ export const PolarChart: React.FunctionComponent = React.forwar }, [innerRadius, outerRadius, rScaleDomain, rTickValues, aTickValues, rScale, aScale, props.shape, classes]); const renderPolarTicks = React.useCallback(() => { - const radialAxisAngle = props.direction === 'clockwise' ? 0 : Math.PI / 2; + const radialAxisAngle = Math.PI / 2; const radialPoint1 = d3PointRadial(radialAxisAngle, innerRadius); const radialPoint2 = d3PointRadial(radialAxisAngle, outerRadius); @@ -338,7 +339,7 @@ export const PolarChart: React.FunctionComponent = React.forwar ); - }, [rTickValues, aTickValues, rScale, aScale, outerRadius, classes, props.direction]); + }, [rTickValues, aTickValues, rScale, aScale, outerRadius, classes]); const getActiveLegends = React.useCallback(() => { return selectedLegends.length > 0 ? selectedLegends : hoveredLegend ? [hoveredLegend] : []; @@ -358,7 +359,7 @@ export const PolarChart: React.FunctionComponent = React.forwar .angle(d => aScale(d.theta)) .innerRadius(innerRadius) .outerRadius(d => rScale(d.r as any)!) - .curve(d3CurveLinearClosed); + .curve(getCurveFactory(series.lineOptions?.curve, d3CurveLinearClosed)); const shouldHighlight = legendHighlighted(series.legend); return ( @@ -377,7 +378,8 @@ export const PolarChart: React.FunctionComponent = React.forwar (series: AreaPolarSeries | LinePolarSeries) => { const radialLine = d3LineRadial() .angle(d => aScale(d.theta)) - .radius(d => rScale(d.r as any)!); + .radius(d => rScale(d.r as any)!) + .curve(getCurveFactory(series.lineOptions?.curve)); return ( = React.forwar fill="none" stroke={series.color} strokeOpacity={legendHighlighted(series.legend) ? 1 : 0.1} - strokeWidth={series.lineOptions?.strokeWidth ?? 2} + strokeWidth={series.lineOptions?.strokeWidth ?? 3} strokeDasharray={series.lineOptions?.strokeDasharray} strokeDashoffset={series.lineOptions?.strokeDashoffset} strokeLinecap={series.lineOptions?.strokeLinecap} diff --git a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts index 6a905ee150e747..180506a4950705 100644 --- a/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts @@ -69,7 +69,7 @@ export const createRadialScale = ( scale.range(range); scale.nice(); - const tickCount = opts.tickCount ?? 3; + const tickCount = opts.tickCount ?? 4; let tickFormat; let customTickValues = Array.isArray(opts.tickValues) ? (opts.tickValues as (number | Date)[]) : undefined; if (scaleType === 'date') {