diff --git a/change/@fluentui-react-charts-cf1a832b-57b7-4532-95d6-f6f285a103da.json b/change/@fluentui-react-charts-cf1a832b-57b7-4532-95d6-f6f285a103da.json new file mode 100644 index 0000000000000..e68de2649e459 --- /dev/null +++ b/change/@fluentui-react-charts-cf1a832b-57b7-4532-95d6-f6f285a103da.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: Add PolarChart", + "packageName": "@fluentui/react-charts", + "email": "kumarkshitij@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/charts/react-charts/library/bundle-size/PolarChart.fixture.js b/packages/charts/react-charts/library/bundle-size/PolarChart.fixture.js new file mode 100644 index 0000000000000..4c263df382f32 --- /dev/null +++ b/packages/charts/react-charts/library/bundle-size/PolarChart.fixture.js @@ -0,0 +1,7 @@ +import { PolarChart } from '@fluentui/react-charts'; + +console.log(PolarChart); + +export default { + name: 'PolarChart', +}; diff --git a/packages/charts/react-charts/library/etc/react-charts.api.md b/packages/charts/react-charts/library/etc/react-charts.api.md index 687f9bd4042e0..4992e4c819a59 100644 --- a/packages/charts/react-charts/library/etc/react-charts.api.md +++ b/packages/charts/react-charts/library/etc/react-charts.api.md @@ -110,6 +110,13 @@ export interface AreaChartStyleProps extends CartesianChartStyleProps { export interface AreaChartStyles extends CartesianChartStyles { } +// @public +export interface AreaPolarSeries extends DataSeries { + data: PolarDataPoint[]; + lineOptions?: LineChartLineOptions; + type: 'areapolar'; +} + // @public export type AxisCategoryOrder = 'default' | 'data' | string[] | 'category ascending' | 'category descending' | 'total ascending' | 'total descending' | 'min ascending' | 'min descending' | 'max ascending' | 'max descending' | 'sum ascending' | 'sum descending' | 'mean ascending' | 'mean descending' | 'median ascending' | 'median descending'; @@ -1420,6 +1427,13 @@ export interface LineDataInVerticalStackedBarChart { yAxisCalloutData?: string; } +// @public +export interface LinePolarSeries extends DataSeries { + data: PolarDataPoint[]; + lineOptions?: LineChartLineOptions; + type: 'linepolar'; +} + // @public export interface LineSeries extends DataSeries { data: DataPointV2[]; @@ -1491,6 +1505,73 @@ export interface ModifiedCartesianChartProps extends CartesianChartProps { yAxisType?: YAxisType; } +// @public +export type PolarAxisProps = AxisProps & { + tickValues?: number[] | Date[] | string[]; + tickFormat?: string; + tickCount?: number; + categoryOrder?: AxisCategoryOrder; + scaleType?: AxisScaleType; + rangeStart?: number | Date; + rangeEnd?: number | Date; +}; + +// @public (undocumented) +export const PolarChart: React_2.FunctionComponent; + +// @public +export interface PolarChartProps { + angularAxis?: PolarAxisProps & { + unit?: 'radians' | 'degrees'; + }; + chartTitle?: string; + componentRef?: React_2.Ref; + culture?: string; + data: (AreaPolarSeries | LinePolarSeries | ScatterPolarSeries)[]; + dateLocalizeOptions?: Intl.DateTimeFormatOptions; + direction?: 'clockwise' | 'counterclockwise'; + height?: number; + hideLegend?: boolean; + hideTooltip?: boolean; + hole?: number; + // (undocumented) + legendProps?: Partial; + margins?: Margins; + radialAxis?: PolarAxisProps; + shape?: 'circle' | 'polygon'; + styles?: PolarChartStyles; + useUTC?: boolean; + width?: number; +} + +// @public +export interface PolarChartStyleProps { +} + +// @public +export interface PolarChartStyles { + chart?: string; + chartWrapper?: string; + gridLineInner?: string; + gridLineOuter?: string; + legendContainer?: string; + root?: string; + tickLabel?: string; +} + +// @public +export interface PolarDataPoint { + angularAxisCalloutData?: string; + callOutAccessibilityData?: AccessibilityProps; + color?: string; + markerSize?: number; + onClick?: () => void; + r: string | number | Date; + radialAxisCalloutData?: string; + text?: string; + theta: string | number; +} + // @public (undocumented) export interface PopoverComponentStyles { // (undocumented) @@ -1689,6 +1770,12 @@ export interface ScatterChartStyles extends CartesianChartStyles { markerLabel?: string; } +// @public +export interface ScatterPolarSeries extends DataSeries { + data: PolarDataPoint[]; + type: 'scatterpolar'; +} + // @public export interface Schema { plotlySchema: any; 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 0000000000000..0bcf1067a40eb --- /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/DeclarativeChart/DeclarativeChart.tsx b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx index 221aafff0b5ca..64d934f1a7b30 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,14 @@ import { transformPlotlyJsonToVBCProps, transformPlotlyJsonToChartTableProps, transformPlotlyJsonToScatterChartProps, - projectPolarToCartesian, getAllupLegendsProps, NON_PLOT_KEY_PREFIX, SINGLE_REPEAT, transformPlotlyJsonToFunnelChartProps, transformPlotlyJsonToGanttChartProps, transformPlotlyJsonToAnnotationChartProps, + transformPlotlyJsonToPolarChartProps, + DEFAULT_POLAR_SUBPLOT, } from './PlotlySchemaAdapter'; import { getChartTitleInlineStyles } from '../../utilities/index'; import type { ColorwayType } from './PlotlyColorAdapter'; @@ -57,6 +58,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'; @@ -80,6 +82,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'; @@ -244,6 +247,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; @@ -318,6 +325,10 @@ const chartMap: ChartTypeMap = { transformer: transformPlotlyJsonToFunnelChartProps, renderer: FunnelChart, }, + scatterpolar: { + transformer: transformPlotlyJsonToPolarChartProps, + renderer: ResponsivePolarChart, + }, fallback: { transformer: transformPlotlyJsonToVSBCProps, renderer: ResponsiveVerticalStackedBarChart, @@ -459,23 +470,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; @@ -489,7 +483,10 @@ export const DeclarativeChart: React.FunctionComponent = traceKey = `${NON_PLOT_KEY_PREFIX}${nonCartesianTraceCount + 1}`; nonCartesianTraceCount++; } else { - traceKey = (trace as PlotData).xaxis ?? DEFAULT_XAXIS; + traceKey = + chart.validTracesInfo![index].type === 'scatterpolar' + ? (trace as { subplot?: string }).subplot ?? DEFAULT_POLAR_SUBPLOT + : (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 293a508981ae7..b2dc75e77db0e 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, @@ -94,6 +95,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 { PolarAxisProps, PolarChartProps } from '../PolarChart/PolarChart.types'; import { ChartAnnotation, ChartAnnotationArrowHead, @@ -112,6 +114,10 @@ type DomainInterval = { end: number; }; +type ExtDomainInterval = DomainInterval & { + cellName: string; +}; + export type AxisProperties = { xAnnotation?: string; yAnnotation?: string; @@ -3175,154 +3181,109 @@ export const transformPlotlyJsonToFunnelChartProps = ( } as FunnelChartProps; }; -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; - } +export const transformPlotlyJsonToPolarChartProps = ( + input: PlotlySchema, + isMultiPlot: boolean, + colorMap: React.RefObject>, + colorwayType: ColorwayType, + isDarkTheme?: boolean, +): PolarChartProps => { + const polarData: PolarChartProps['data'] = []; + const { legends, hideLegend } = getLegendProps(input.data, input.layout, isMultiPlot); + const resolveRValue = getAxisValueResolver(getPolarAxis(input.data, 'r', input.layout)._type); - // 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; + input.data.forEach((series: Partial, index: number) => { + const legend = legends[index]; - // Compute tick positions if categorical - let uniqueTheta: Datum[] = []; - let categorical = false; - if (!isNumberArray(thetas)) { - uniqueTheta = Array.from(new Set(thetas)); - categorical = true; - } + 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 thetaUnit = (series as { thetaunit?: 'radians' | 'degrees' | 'gradians' }).thetaunit; - for (let ptindex = 0; ptindex < rVals.length; ptindex++) { - if (isInvalidValue(thetas?.[ptindex]) || isInvalidValue(rVals?.[ptindex])) { - continue; - } + 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(resolveRValue(r)) || isInvalidValue(theta)) { + return; + } + + return { + r: resolveRValue(r)!, + theta: + typeof theta === 'number' + ? thetaUnit === 'radians' + ? (theta * 180) / Math.PI + : thetaUnit === 'gradians' + ? theta * 0.9 + : theta + : (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') || [], + }; - // 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; + if (isAreaTrace || isLineTrace) { + polarData.push({ + type: isAreaTrace ? 'areapolar' : 'linepolar', + ...commonProps, + lineOptions, + }); } 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!); + polarData.push({ + type: 'scatterpolar', + ...commonProps, + }); } } - // 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, + return { + data: polarData, + width: input.layout?.width, + height: input.layout?.height ?? 400, + hideLegend, + ...getPolarAxisProps(input.data, input.layout), + // ...getTitles(input.layout), }; - // Attach originX as custom properties - (projection.layout as { __polarOriginX?: number }).__polarOriginX = originX ?? undefined; - - return projection; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -3689,8 +3650,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; @@ -3716,9 +3677,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')) { @@ -3733,9 +3695,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); } @@ -3746,13 +3709,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); @@ -3760,6 +3725,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 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] : [], @@ -3785,7 +3767,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)) { @@ -3799,11 +3781,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 @@ -3817,11 +3794,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)) { @@ -3836,17 +3813,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; @@ -4264,3 +4237,132 @@ const parseLocalDate = (value: string | number) => { } return new Date(value); }; + +type PolarDataKey = 'r' | 'theta'; +interface PolarAxisObject extends Partial { + _type: AxisType; + _dataKey: PolarDataKey; +} + +const POLAR_AXIS_BY_DATA_KEY: Record = { + r: 'radialAxis', + theta: 'angularAxis', +}; +export const DEFAULT_POLAR_SUBPLOT = 'polar'; + +const getPolarLayout = ( + trace: Partial, + layout: Partial | undefined, +): Partial | undefined => { + const subplotId = ((trace as { subplot?: string })?.subplot || DEFAULT_POLAR_SUBPLOT) as keyof Layout; + return layout?.[subplotId]; +}; + +const getValidAxisValues = (data: Data[], dataKey: PolarDataKey): Datum[] => { + const values: Datum[] = []; + data.forEach((series: Partial) => { + if (isArrayOrTypedArray(series[dataKey])) { + (series[dataKey] as Datum[]).forEach(val => { + if (!isInvalidValue(val)) { + values.push(val as Datum); + } + }); + } + }); + return values; +}; + +const getPolarAxisType = (data: Data[], dataKey: PolarDataKey, declaredType: AxisType | undefined): AxisType => { + if (['linear', 'log', 'date', 'category'].includes(declaredType ?? '')) { + return declaredType!; + } + + const values = getValidAxisValues(data, dataKey); + if (isNumberArray(values) && !isYearArray(values)) { + return 'linear'; + } + if (isDateArray(values)) { + return 'date'; + } + return 'category'; +}; + +const getPolarAxis = (data: Data[], dataKey: PolarDataKey, layout: Partial | undefined): PolarAxisObject => { + const polarLayout = getPolarLayout(data[0] as Partial, layout); + const ax = polarLayout?.[POLAR_AXIS_BY_DATA_KEY[dataKey].toLowerCase() as 'radialaxis' | 'angularaxis']; + return { + ...ax, + _dataKey: dataKey, + _type: getPolarAxisType(data, dataKey, ax?.type), + }; +}; + +const getPolarAxisTickProps = (data: Data[], ax: PolarAxisObject): PolarAxisProps => { + const props: PolarAxisProps = {}; + + if ((!ax.tickmode || ax.tickmode === 'array') && isArrayOrTypedArray(ax.tickvals)) { + const tickValues = ax._type === '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, ax._type); + const tick0 = plotlyTick0(ax.tick0, ax._type, 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 getPolarAxisCategoryOrder = (data: Data[], ax: PolarAxisObject) => { + if (ax._type !== '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 values = getValidAxisValues(data, ax._dataKey); + const categoriesInTraceOrder = Array.from(new Set(values as string[])); + return ax.autorange === 'reversed' ? categoriesInTraceOrder.reverse() : categoriesInTraceOrder; + } + + return ax.categoryorder; +}; + +const getPolarAxisProps = (data: Data[], layout: Partial | undefined) => { + const props: Partial = {}; + + (Object.keys(POLAR_AXIS_BY_DATA_KEY) as PolarDataKey[]).forEach(dataKey => { + const propName = POLAR_AXIS_BY_DATA_KEY[dataKey]; + const ax = getPolarAxis(data, dataKey, layout); + + props[propName] = { + scaleType: ax._type === 'log' ? 'log' : 'default', + categoryOrder: getPolarAxisCategoryOrder(data, ax), + tickFormat: ax.tickformat, + ...getPolarAxisTickProps(data, ax), + ...(isArrayOrTypedArray(ax.range) ? { rangeStart: ax.range![0], rangeEnd: ax.range![1] } : {}), + }; + + if (propName === 'angularAxis') { + props[propName].unit = (ax as { thetaunit?: 'radians' | 'degrees' }).thetaunit; + props.direction = ax.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 new file mode 100644 index 0000000000000..c4d8a880d4c88 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx @@ -0,0 +1,691 @@ +'use client'; + +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 { AreaPolarSeries, LinePolarSeries, PolarDataPoint, ScatterPolarSeries } from '../../types/DataPoint'; +import { tokens } from '@fluentui/react-theme'; +import { Legend, Legends } from '../Legends/index'; +import { + createRadialScale, + getContinuousScaleDomain, + getScaleType, + EPSILON, + createAngularScale, + formatAngle, +} from './PolarChart.utils'; +import { ChartPopover } from '../CommonComponents/ChartPopover'; +import { + getColorFromToken, + getCurveFactory, + getNextColor, + isPlottable, + sortAxisCategories, +} from '../../utilities/index'; +import { extent as d3Extent } from 'd3-array'; +import { useArrowNavigationGroup } from '@fluentui/react-tabster'; +import { formatToLocaleString } from '@fluentui/chart-utilities'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; + +const DEFAULT_LEGEND_HEIGHT = 32; +const LABEL_WIDTH = 36; +const LABEL_HEIGHT = 16; +const LABEL_OFFSET = 10; +const TICK_SIZE = 6; +const MIN_MARKER_SIZE_PX = 2; +const MAX_MARKER_SIZE_PX = 16; +const MIN_MARKER_SIZE_PX_MARKERS_ONLY = 4; + +export const PolarChart: React.FunctionComponent = React.forwardRef( + (props, forwardedRef) => { + const { chartContainerRef, legendsRef } = useImageExport(props.componentRef, props.hideLegend, false); + const legendContainerRef = React.useRef(null); + const { targetDocument } = useFluent(); + const _window = targetDocument?.defaultView; + + const [containerWidth, setContainerWidth] = React.useState(200); + const [containerHeight, setContainerHeight] = React.useState(200); + 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 || []); + const [activePoint, setActivePoint] = React.useState(''); + + React.useEffect(() => { + if (chartContainerRef.current) { + const { width, height } = chartContainerRef.current.getBoundingClientRect(); + setContainerWidth(width); + setContainerHeight(height); + } + }, [chartContainerRef]); + React.useEffect(() => { + if (props.hideLegend) { + setLegendContainerHeight(0); + } else if (legendContainerRef.current) { + const { height } = legendContainerRef.current.getBoundingClientRect(); + const marginTop = _window?.getComputedStyle(legendContainerRef.current).marginTop || '0px'; + setLegendContainerHeight(Math.max(height, DEFAULT_LEGEND_HEIGHT) + parseFloat(marginTop)); + } + }, [props.hideLegend, _window]); + + React.useEffect(() => { + setSelectedLegends(props.legendProps?.selectedLegends || []); + }, [props.legendProps?.selectedLegends]); + + 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 = React.useMemo(() => props.width || containerWidth, [props.width, containerWidth]); + const svgHeight = React.useMemo( + () => (props.height || containerHeight) - legendContainerHeight, + [props.height, containerHeight, legendContainerHeight], + ); + const outerRadius = React.useMemo( + () => Math.min(svgWidth - (margins.left + margins.right), svgHeight - (margins.top + margins.bottom)) / 2, + [svgWidth, svgHeight, margins], + ); + const innerRadius = React.useMemo( + () => Math.max(0, Math.min(Math.abs(props.hole || 0), 1)) * outerRadius, + [props.hole, outerRadius], + ); + + const legendColorMap = React.useRef>({}); + const chartData = React.useMemo(() => { + legendColorMap.current = {}; + let colorIndex = 0; + const renderingOrder = ['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 renderingOrder.indexOf(a.type) - renderingOrder.indexOf(b.type); + }); + }, [props.data]); + + const mapCategoryToValues = React.useCallback( + (isAngularAxis?: boolean) => { + const categoryToValues: Record = {}; + chartData.forEach(series => { + series.data.forEach(point => { + const category = (isAngularAxis ? point.theta : point.r) as string; + if (!categoryToValues[category]) { + categoryToValues[category] = []; + } + const value = isAngularAxis ? point.r : point.theta; + if (typeof value === 'number') { + categoryToValues[category].push(value); + } + }); + }); + return categoryToValues; + }, + [chartData], + ); + + const getOrderedRCategories = React.useCallback(() => { + return sortAxisCategories(mapCategoryToValues(), props.radialAxis?.categoryOrder); + }, [mapCategoryToValues, props.radialAxis?.categoryOrder]); + + const getOrderedACategories = 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( + () => + getScaleType(rValues, { + scaleType: props.radialAxis?.scaleType, + supportsLog: true, + }), + [rValues, props.radialAxis?.scaleType], + ); + const rScaleDomain = React.useMemo( + () => + rScaleType === 'category' + ? getOrderedRCategories() + : getContinuousScaleDomain(rScaleType, rValues as (number | Date)[], { + rangeStart: props.radialAxis?.rangeStart, + rangeEnd: props.radialAxis?.rangeEnd, + }), + [getOrderedRCategories, 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, + props.culture, + props.dateLocalizeOptions, + props.radialAxis?.tick0, + props.radialAxis?.tickCount, + props.radialAxis?.tickFormat, + props.radialAxis?.tickStep, + props.radialAxis?.tickText, + props.radialAxis?.tickValues, + props.useUTC, + ], + ); + + const aValues = React.useMemo( + () => chartData.flatMap(series => series.data.map(point => point.theta)), + [chartData], + ); + const aScaleType = React.useMemo( + () => + getScaleType(aValues, { + scaleType: props.angularAxis?.scaleType, + }), + [aValues, props.angularAxis?.scaleType], + ); + const aDomain = React.useMemo( + () => + aScaleType === 'category' + ? getOrderedACategories() + : (getContinuousScaleDomain(aScaleType, aValues as number[]) as number[]), + [getOrderedACategories, aScaleType, aValues], + ); + const { + scale: aScale, + tickValues: aTickValues, + tickLabels: aTickLabels, + } = React.useMemo( + () => + createAngularScale(aScaleType, 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, + direction: props.direction, + unit: props.angularAxis?.unit, + }), + [ + aScaleType, + aDomain, + props.angularAxis?.tick0, + props.angularAxis?.tickCount, + props.angularAxis?.tickFormat, + props.angularAxis?.tickStep, + props.angularAxis?.tickText, + props.angularAxis?.tickValues, + props.angularAxis?.unit, + props.culture, + props.direction, + ], + ); + + const classes = usePolarChartStyles(props); + + const renderPolarGrid = React.useCallback(() => { + const extRTickValues = []; + const rDomain = rScale.domain(); + if (innerRadius > 0 && rDomain[0] !== rTickValues[0]) { + extRTickValues.push(rDomain[0]); + } + extRTickValues.push(...rTickValues); + if (rDomain[rDomain.length - 1] !== rTickValues[rTickValues.length - 1]) { + extRTickValues.push(rDomain[rDomain.length - 1]); + } + + return ( + + + {extRTickValues.map((r, rIndex) => { + const className = rIndex === extRTickValues.length - 1 ? classes.gridLineOuter : classes.gridLineInner; + + if (props.shape === 'polygon') { + let d = ''; + aTickValues.forEach((a, aIndex) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const radialPoint = d3PointRadial(aScale(a), rScale(r as any)!); + d += (aIndex === 0 ? 'M' : 'L') + radialPoint.join(',') + ' '; + }); + d += 'Z'; + + return ; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ; + })} + + + {aTickValues.map((a, aIndex) => { + const radialPoint1 = d3PointRadial(aScale(a), innerRadius); + const radialPoint2 = d3PointRadial(aScale(a), outerRadius); + + return ( + + ); + })} + + + ); + }, [ + innerRadius, + outerRadius, + rTickValues, + aTickValues, + rScale, + aScale, + props.shape, + classes.gridLineInner, + classes.gridLineOuter, + ]); + + const renderPolarTicks = React.useCallback(() => { + const radialAxisAngle = props.direction === 'clockwise' ? 0 : Math.PI / 2; + const radialAxisStartPoint = d3PointRadial(radialAxisAngle, innerRadius); + const radialAxisEndPoint = d3PointRadial(radialAxisAngle, outerRadius); + // (0, pi] + const sign = radialAxisAngle > EPSILON && radialAxisAngle - Math.PI < EPSILON ? 1 : -1; + + return ( + + + + {rTickValues.map((r, rIndex) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [pointX, pointY] = d3PointRadial(radialAxisAngle, rScale(r as any)!); + return ( + + + 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]} + + + ); + })} + + + {aTickValues.map((a, aIndex) => { + const angle = aScale(a); + const [pointX, pointY] = d3PointRadial(angle, outerRadius + LABEL_OFFSET); + + return ( + Math.PI + ? 'end' + : 'start' + } + dominantBaseline="middle" + aria-hidden={true} + className={classes.tickLabel} + > + {aTickLabels[aIndex]} + + ); + })} + + + ); + }, [ + rTickValues, + aTickValues, + rScale, + aScale, + outerRadius, + classes.gridLineOuter, + classes.tickLabel, + aTickLabels, + innerRadius, + rTickLabels, + props.direction, + ]); + + 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], + ); + + const renderRadialArea = React.useCallback( + (series: AreaPolarSeries) => { + const radialArea = d3AreaRadial() + .angle(d => aScale(d.theta)) + .innerRadius(innerRadius) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .outerRadius(d => rScale(d.r as any)!) + .curve(getCurveFactory(series.lineOptions?.curve, d3CurveLinearClosed)) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .defined(d => isPlottable(aScale(d.theta), rScale(d.r as any))); + const shouldHighlight = legendHighlighted(series.legend); + + return ( + + ); + }, + [innerRadius, rScale, aScale, legendHighlighted], + ); + + const renderRadialLine = React.useCallback( + (series: AreaPolarSeries | LinePolarSeries) => { + const radialLine = d3LineRadial() + .angle(d => aScale(d.theta)) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .radius(d => rScale(d.r as any)!) + .curve(getCurveFactory(series.lineOptions?.curve)) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .defined(d => isPlottable(aScale(d.theta), rScale(d.r as any))); + + return ( + + ); + }, + [rScale, aScale, 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.angularAxisCalloutData ?? formatAngle(point.theta, props.angularAxis?.unit)); + setPopoverLegend(legend); + setPopoverColor(point.color!); + setPopoverYValue( + point.radialAxisCalloutData ?? (formatToLocaleString(point.r, props.culture, props.useUTC) as string), + ); + setActivePoint(pointId); + }, + [legendHighlighted, props.angularAxis?.unit, props.culture, props.useUTC], + ); + + const hidePopover = React.useCallback(() => { + setPopoverOpen(false); + setActivePoint(''); + }, []); + + const markersOnlyMode = React.useMemo( + () => chartData.filter(s => s.type === 'areapolar' || s.type === 'linepolar').length === 0, + [chartData], + ); + + const renderRadialPoints = React.useCallback( + (series: AreaPolarSeries | LinePolarSeries | ScatterPolarSeries, seriesIndex: number) => { + const shouldHighlight = legendHighlighted(series.legend); + return ( + + {series.data.map((point, pointIndex) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!isPlottable(aScale(point.theta), rScale(point.r as any))) { + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [x, y] = d3PointRadial(aScale(point.theta), rScale(point.r as any)!); + const id = `${seriesIndex}-${pointIndex}`; + const isActive = activePoint === id; + const minPx = markersOnlyMode ? MIN_MARKER_SIZE_PX_MARKERS_ONLY : MIN_MARKER_SIZE_PX; + let radius = minPx; + if (typeof point.markerSize !== 'undefined' && minMarkerSize !== maxMarkerSize) { + radius = + minPx + + ((point.markerSize - minMarkerSize!) / (maxMarkerSize! - minMarkerSize!)) * + (MAX_MARKER_SIZE_PX - minPx); + } + + const xValue = + point.radialAxisCalloutData || (formatToLocaleString(point.r, props.culture, props.useUTC) as string); + const legend = series.legend; + const yValue = point.angularAxisCalloutData || formatAngle(point.theta, props.angularAxis?.unit); + 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, + markersOnlyMode, + props.angularAxis?.unit, + props.culture, + props.useUTC, + ], + ); + + 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(() => { + if (props.hideLegend) { + return null; + } + + const legends: Legend[] = Object.keys(legendColorMap.current).map(legendTitle => { + return { + title: legendTitle, + color: legendColorMap.current[legendTitle], + hoverAction: () => { + setHoveredLegend(legendTitle); + }, + onMouseOutAction: () => { + setHoveredLegend(''); + }, + }; + }); + + return ( +
+ +
+ ); + }, [props.hideLegend, props.legendProps, legendsRef, onLegendSelectionChange, classes.legendContainer]); + + const focusAttributes = useArrowNavigationGroup({ axis: 'horizontal' }); + + return ( +
+
+ + {renderPolarGrid()} + + {chartData.map((series, seriesIndex) => { + return ( + + {series.type === 'areapolar' && renderRadialArea(series)} + {(series.type === 'areapolar' || series.type === 'linepolar') && renderRadialLine(series)} + {renderRadialPoints(series, seriesIndex)} + + ); + })} + + {renderPolarTicks()} + +
+ {renderLegends()} + {!props.hideTooltip && ( + + )} +
+ ); + }, +); + +PolarChart.displayName = 'PolarChart'; 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 0000000000000..a433559d2be01 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts @@ -0,0 +1,216 @@ +import * as React from 'react'; +import { + AreaPolarSeries, + AxisCategoryOrder, + AxisProps, + AxisScaleType, + Chart, + LinePolarSeries, + Margins, + ScatterPolarSeries, +} from '../../types/DataPoint'; +import { LegendsProps } from '../Legends/Legends.types'; + +/** + * Configuration options for a polar axis. + * {@docCategory PolarChart} + */ +export type PolarAxisProps = AxisProps & { + /** + * Values at which ticks should be placed on the axis. + */ + tickValues?: number[] | Date[] | string[]; + + /** + * Format string for the axis ticks. + * For numbers, see: https://d3js.org/d3-format. + * And for dates see: https://d3js.org/d3-time-format. + */ + tickFormat?: string; + + /** + * Number of ticks to display on the axis. + */ + tickCount?: number; + + /** + * Defines the order of categories on the axis. + * @default 'default' + */ + categoryOrder?: AxisCategoryOrder; + + /** + * Scale type for the axis. + * @default 'default' + */ + scaleType?: AxisScaleType; + + /** + * Start value of the axis range. + */ + rangeStart?: number | Date; + + /** + * End value of the axis range. + */ + rangeEnd?: number | Date; +}; + +/** + * Polar Chart properties + * {@docCategory PolarChart} + */ +export interface PolarChartProps { + /** + * Data series to be rendered in the polar chart. + */ + data: (AreaPolarSeries | LinePolarSeries | ScatterPolarSeries)[]; + + /** + * Width of the polar chart. + * @default 200 + */ + width?: number; + + /** + * Height of the polar chart. + * @default 200 + */ + height?: number; + + /** + * Margins around the chart area. + */ + margins?: Margins; + + /** + * If true, hides the legend. + * @default false + */ + hideLegend?: boolean; + + /** + * If true, hides the tooltip. + * @default false + */ + hideTooltip?: boolean; + + /* + * Properties for customizing the legend. + */ + legendProps?: Partial; + + /** + * Style properties for the polar chart. + */ + styles?: PolarChartStyles; + + /** + * Title of the chart. + */ + chartTitle?: string; + + /** + * Fraction of the radius to cut out from the center of the chart. + * Accepts values in the range [0, 1]. + */ + hole?: number; + + /** + * Shape of the polar chart. + * @default 'circle' + */ + shape?: 'circle' | 'polygon'; + + /** + * Direction in which the chart is drawn. + * @default 'counterclockwise' + */ + direction?: 'clockwise' | 'counterclockwise'; + + /** + * Configuration options for the radial axis. + */ + radialAxis?: PolarAxisProps; + + /** + * Configuration options for the angular axis. + */ + angularAxis?: PolarAxisProps & { + /** + * Format unit for angular values. + * @default 'degrees' + */ + unit?: 'radians' | 'degrees'; + }; + + /** + * 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; + + /** + * Locale identifier string used to format numbers and dates according to the specified culture. + * Example: 'en-US', 'fr-FR'. + */ + culture?: string; + + /** + * Options for localizing date values. + */ + dateLocalizeOptions?: Intl.DateTimeFormatOptions; + + /** + * If true, date values are treated as UTC dates. + * @default false + */ + useUTC?: boolean; +} + +/** + * Polar Chart style properties + * {@docCategory PolarChart} + */ +export interface PolarChartStyleProps {} + +/** + * Polar Chart styles + * {@docCategory PolarChart} + */ +export interface PolarChartStyles { + /** + * Style for the root element. + */ + root?: string; + + /** + * Style for the chart wrapper element. + */ + chartWrapper?: string; + + /** + * Style for the chart element. + */ + chart?: string; + + /** + * Style for the inner grid lines. + */ + gridLineInner?: string; + + /** + * Style for the outer grid lines. + */ + gridLineOuter?: string; + + /** + * Style for the tick labels. + */ + tickLabel?: string; + + /** + * Style for the legend container. + */ + legendContainer?: 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 new file mode 100644 index 0000000000000..73d5cc5a1eca2 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts @@ -0,0 +1,259 @@ +import { + scaleBand as d3ScaleBand, + scaleLinear as d3ScaleLinear, + scaleLog as d3ScaleLog, + scaleTime as d3ScaleTime, + scaleUtc as d3ScaleUtc, + NumberValue, + ScaleBand, + ScaleContinuousNumeric, + ScaleTime, +} from 'd3-scale'; +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, + precisionRound, +} from '../../utilities/utilities'; +import { + isInvalidValue, + formatToLocaleString, + getMultiLevelDateTimeFormatOptions, + formatDateToLocaleString, +} from '@fluentui/chart-utilities'; +import { timeFormat as d3TimeFormat, utcFormat as d3UtcFormat } from 'd3-time-format'; +import { PolarChartProps } from './PolarChart.types'; + +export const EPSILON = 1e-6; + +export const createRadialScale = ( + scaleType: string, + domain: (string | number | Date)[], + range: number[], + opts: { + useUTC?: boolean; + tickCount?: number; + tickValues?: (string | number | Date)[]; + tickText?: string[]; + tickFormat?: string; + culture?: string; + tickStep?: number | string; + tick0?: number | Date; + dateLocalizeOptions?: Intl.DateTimeFormatOptions; + } = {}, +): { + scale: ScaleBand | ScaleContinuousNumeric | ScaleTime; + tickValues: (string | number | Date)[]; + tickLabels: string[]; +} => { + if (scaleType === 'category') { + const scale = d3ScaleBand() + .domain(domain as string[]) + .range(range) + .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; + if (scaleType === 'date') { + scale = opts.useUTC ? d3ScaleUtc() : d3ScaleTime(); + } else { + scale = scaleType === 'log' ? d3ScaleLog() : d3ScaleLinear(); + } + + scale.domain(domain as (number | Date)[]); + scale.range(range); + scale.nice(); + + const tickCount = opts.tickCount ?? 4; + 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); + + return { scale, tickValues, tickLabels: tickValues.map(tickFormat) }; +}; + +export const getScaleType = ( + values: (string | number | Date)[], + opts: { + scaleType?: AxisScaleType; + supportsLog?: boolean; + } = {}, +): string => { + 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 getContinuousScaleDomain = ( + scaleType: string, + values: (number | Date)[], + opts: { + rangeStart?: number | Date; + rangeEnd?: number | Date; + } = {}, +): (number | Date)[] => { + 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 (!isInvalidValue(opts.rangeStart)) { + min = opts.rangeStart; + } + if (!isInvalidValue(opts.rangeEnd)) { + max = opts.rangeEnd; + } + + if (isInvalidValue(min) || isInvalidValue(max)) { + return []; + } + return [min!, max!]; +}; + +const degToRad = (deg: number) => (deg * Math.PI) / 180; + +const radToDeg = (rad: number) => (rad * 180) / Math.PI; + +const normalizeAngle = (deg: number, direction: PolarChartProps['direction']) => + (((direction === 'clockwise' ? deg : 450 - deg) % 360) + 360) % 360; + +export const createAngularScale = ( + scaleType: string, + domain: (string | number | Date)[], + opts: { + tickCount?: number; + tickValues?: (string | number | Date)[]; + tickText?: string[]; + tickFormat?: string; + culture?: string; + tickStep?: number | string; + tick0?: number | Date; + direction?: PolarChartProps['direction']; + unit?: NonNullable['unit']; + } = {}, +): { scale: (v: string | number) => number; tickValues: (string | number)[]; tickLabels: string[] } => { + if (scaleType === 'category') { + const categoryIndexMap: Record = {}; + (domain as string[]).forEach((d, i) => { + categoryIndexMap[d] = i; + }); + const period = 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(normalizeAngle(categoryIndexMap[v] * period, opts.direction)), + tickValues, + tickLabels: tickValues.map(tickFormat), + }; + } + + 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 formatAngle(domainValue, opts.unit); + }; + if (opts.tickStep) { + customTickValues = generateNumericTicks(scaleType as AxisScaleType, opts.tickStep, opts.tick0, [ + 0, + (opts.unit === 'radians' ? 2 * Math.PI : 360) - EPSILON, + ])?.map(v => (opts.unit === 'radians' ? radToDeg(v) : v)); + } + const tickValues = customTickValues ?? d3Range(0, 360, 360 / (opts.tickCount ?? 8)); + + return { + scale: (v: number) => degToRad(normalizeAngle(v, opts.direction)), + tickValues, + tickLabels: tickValues.map(tickFormat), + }; +}; + +export const formatAngle = ( + value: string | number, + unit: NonNullable['unit'], +): string => + typeof value === 'string' + ? value + : unit === 'radians' + ? `${precisionRound(value / 180, 6)}π` + : `${precisionRound(value, 6)}°`; 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 0000000000000..8e104d4467354 --- /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 0000000000000..a05319080edb0 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts @@ -0,0 +1,89 @@ +'use client'; + +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 + */ +export const polarChartClassNames: SlotClassNames = { + root: 'fui-polar__root', + chartWrapper: 'fui-polar__chartWrapper', + chart: 'fui-polar__chart', + gridLineInner: 'fui-polar__gridLineInner', + gridLineOuter: 'fui-polar__gridLineOuter', + tickLabel: 'fui-polar__tickLabel', + legendContainer: 'fui-polar__legendContainer', +}; + +const useStyles = makeStyles({ + root: { + ...typographyStyles.body1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + width: '100%', + height: '100%', + textAlign: 'left', + }, + + chart: { + display: 'block', + }, + + gridLine: { + fill: 'none', + stroke: tokens.colorNeutralForeground1, + strokeWidth: '1px', + }, + + gridLineInner: { + opacity: 0.2, + }, + + gridLineOuter: { + opacity: 1, + }, + + tickLabel: { + ...typographyStyles.caption2Strong, + fill: tokens.colorNeutralForeground1, + }, + + legendContainer: { + width: '100%', + }, +}); + +/** + * Apply styling to the PolarChart component + */ +export const usePolarChartStyles = (props: PolarChartProps): PolarChartStyles => { + const baseStyles = useStyles(); + + return { + root: mergeClasses(polarChartClassNames.root, baseStyles.root, props.styles?.root), + chartWrapper: mergeClasses(polarChartClassNames.chartWrapper, props.styles?.chartWrapper), + chart: mergeClasses(polarChartClassNames.chart, baseStyles.chart, props.styles?.chart), + 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), + legendContainer: mergeClasses( + polarChartClassNames.legendContainer, + baseStyles.legendContainer, + props.styles?.legendContainer, + ), + }; +}; diff --git a/packages/charts/react-charts/library/src/index.ts b/packages/charts/react-charts/library/src/index.ts index 3d2a65cadd858..3ce48ceebc709 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 cd3e93a0c4392..004fb8bcfb5ba 100644 --- a/packages/charts/react-charts/library/src/types/DataPoint.ts +++ b/packages/charts/react-charts/library/src/types/DataPoint.ts @@ -1272,3 +1272,108 @@ export interface LineSeries void; } + +/** + * Represents a single data point in a polar series. + */ +export interface PolarDataPoint { + /** + * Radial value of the data point. + */ + r: string | number | Date; + + /** + * Angular value of the data point, specified as a category or in degrees. + */ + 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; + + /** + * Custom text to show in the callout in place of the angular axis value. + */ + 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: PolarDataPoint[]; +} + +/** + * Represents a linepolar series. + */ +export interface LinePolarSeries extends DataSeries { + /** + * Type discriminator: always 'linepolar' for this series. + */ + type: 'linepolar'; + + /** + * Array of data points for the series. + */ + 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/library/src/utilities/utilities.ts b/packages/charts/react-charts/library/src/utilities/utilities.ts index 5eef6b620f429..8d45777af3551 100644 --- a/packages/charts/react-charts/library/src/utilities/utilities.ts +++ b/packages/charts/react-charts/library/src/utilities/utilities.ts @@ -2398,12 +2398,12 @@ export const generateMonthlyTicks = ( return ticks; }; -const generateNumericTicks = ( +export const generateNumericTicks = ( scaleType: AxisScaleType | undefined, tickStep: string | number | undefined, tick0: number | Date | undefined, scaleDomain: number[], -) => { +): number[] | undefined => { const refTick = typeof tick0 === 'number' ? tick0 : 0; if (scaleType === 'log') { @@ -2431,12 +2431,12 @@ const generateNumericTicks = ( } }; -const generateDateTicks = ( +export const generateDateTicks = ( tickStep: string | number | undefined, tick0: number | Date | undefined, scaleDomain: Date[], useUTC?: boolean, -) => { +): Date[] | undefined => { const refTick = tick0 instanceof Date ? tick0 : new Date(DEFAULT_DATE_STRING); if (typeof tickStep === 'number' && tickStep > 0) { 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 0000000000000..8c1c548fae029 --- /dev/null +++ b/packages/charts/react-charts/stories/src/PolarChart/PolarChartDefault.stories.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { PolarChart, PolarChartProps } from '@fluentui/react-charts'; + +const data: PolarChartProps['data'] = [ + { + type: 'areapolar', + legend: 'Mike', + color: '#8884d8', + data: [ + { r: 120, theta: 'Math' }, + { r: 98, theta: 'Chinese' }, + { r: 86, theta: 'English' }, + { r: 99, theta: 'Geography' }, + { r: 85, theta: 'Physics' }, + { r: 65, theta: 'History' }, + ], + }, + { + type: 'areapolar', + legend: 'Lily', + color: '#82ca9d', + data: [ + { r: 110, theta: 'Math' }, + { r: 130, theta: 'Chinese' }, + { r: 130, theta: 'English' }, + { r: 100, theta: 'Geography' }, + { r: 90, theta: 'Physics' }, + { r: 85, theta: 'History' }, + ], + }, +]; + +export const PolarChartBasic = (): JSXElement => { + const [width, setWidth] = React.useState(600); + const [height, setHeight] = React.useState(350); + + 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} +
+
+
+ +
+
+ ); +}; +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 0000000000000..7a3385939a7f9 --- /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: '', + }, + }, + }, +};