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/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/DeclarativeChart/DeclarativeChart.tsx b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx index c94d1e40b513c0..3d8f6945a99516 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,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; @@ -488,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 4f7f9da3933dab..0cfef478d75d73 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, @@ -111,6 +113,10 @@ type DomainInterval = { end: number; }; +type ExtDomainInterval = DomainInterval & { + cellName: string; +}; + export type AxisProperties = { xAnnotation?: string; yAnnotation?: string; @@ -2993,154 +2999,101 @@ 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; - } +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 thetaunit = (input.layout?.polar?.angularaxis as { thetaunit?: 'radians' | 'degrees' } | undefined)?.thetaunit; - // 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); - 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(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') || [], + }; - // 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, + ...getsomething(input.data, 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 @@ -3507,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; @@ -3534,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')) { @@ -3551,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); } @@ -3564,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); @@ -3578,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] : [], @@ -3603,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)) { @@ -3617,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 @@ -3635,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)) { @@ -3654,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; @@ -4072,3 +4037,110 @@ 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'; + const polarLayout = layout?.[subplotId]; + const axType = getAxisType2(values, polarLayout?.[propName.toLowerCase()]); + props[propName] = { + categoryOrder: getAxisCategoryOrderProps2(values, polarLayout?.[propName.toLowerCase()]), + ...getAxisTickProps2(values, polarLayout?.[propName.toLowerCase()]), + tickFormat: '', + title: '', + 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 = 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 new file mode 100644 index 00000000000000..f8e1bc9e871667 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.tsx @@ -0,0 +1,571 @@ +'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, getScaleDomain, getScaleType, EPSILON, createAngularScale } from './PolarChart.utils'; +import { ChartPopover } from '../CommonComponents/ChartPopover'; +import { getColorFromToken, getCurveFactory, getNextColor, sortAxisCategories } 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 = 10; +const TICK_SIZE = 6; +const MIN_PIXEL = 2; +const MAX_PIXEL = 16; + +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, + ); + 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); + } + }, []); + 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]); + + React.useEffect(() => { + setSelectedLegends(props.legendProps?.selectedLegends || []); + }, [JSON.stringify(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 = props.width || containerWidth; + const svgHeight = (props.height || containerHeight) - legendContainerHeight; + 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(() => { + legendColorMap.current = {}; + let colorIndex = 0; + 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( + (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( + () => + getScaleType(rValues, { + scaleType: props.radialAxis?.scaleType, + supportsLog: true, + }), + [rValues, props.radialAxis?.scaleType], + ); + const rScaleDomain = React.useMemo( + () => + 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, + 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 aValues = React.useMemo( + () => chartData.flatMap(series => series.data.map(point => point.theta)), + [chartData], + ); + const aType = React.useMemo( + () => + getScaleType(aValues, { + scaleType: props.angularAxis?.scaleType, + // supportsLog: true, + }), + [aValues, props.angularAxis?.scaleType], + ); + const aDomain = React.useMemo( + () => (aType === 'category' ? getOrderedAValues() : (getScaleDomain(aType, aValues as number[]) as number[])), + [getOrderedAValues, aType, aValues], + ); + const { + scale: aScale, + tickValues: aTickValues, + tickLabels: aTickLabels, + } = React.useMemo( + () => + 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, + direction: props.direction, + }), + [aType, aDomain], + ); + + 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) => { + const radialPoint = d3PointRadial(aScale(a), rScale(r as any)!); + d += (aIndex === 0 ? 'M' : 'L') + radialPoint.join(',') + ' '; + }); + d += 'Z'; + + return ; + } + + return ; + })} + + + {aTickValues.map((a, aIndex) => { + const radialPoint1 = d3PointRadial(aScale(a), innerRadius); + const radialPoint2 = d3PointRadial(aScale(a), outerRadius); + + return ( + + ); + })} + + + ); + }, [innerRadius, outerRadius, rScaleDomain, rTickValues, aTickValues, rScale, aScale, props.shape, classes]); + + const renderPolarTicks = React.useCallback(() => { + const radialAxisAngle = Math.PI / 2; + const radialPoint1 = d3PointRadial(radialAxisAngle, innerRadius); + const radialPoint2 = d3PointRadial(radialAxisAngle, outerRadius); + + return ( + + + + {rTickValues.map((r, rIndex) => { + const [pointX, pointY] = d3PointRadial(radialAxisAngle, rScale(r as any)!); + // (0, pi] + const multiplier = radialAxisAngle > EPSILON && radialAxisAngle - Math.PI < EPSILON ? 1 : -1; + 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]); + + 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) + .outerRadius(d => rScale(d.r as any)!) + .curve(getCurveFactory(series.lineOptions?.curve, d3CurveLinearClosed)); + 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)) + .radius(d => rScale(d.r as any)!) + .curve(getCurveFactory(series.lineOptions?.curve)); + + 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.radialAxisCalloutData ?? point.r); + setPopoverLegend(legend); + setPopoverColor(point.color!); + setPopoverYValue(point.angularAxisCalloutData ?? point.theta); + setActivePoint(pointId); + }, + [], + ); + + const hidePopover = React.useCallback(() => { + setPopoverOpen(false); + setActivePoint(''); + }, []); + + const renderRadialPoints = React.useCallback( + (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); + } + + 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], + ); + + 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]); + + 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 00000000000000..4c1a3efa6a73c8 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.types.ts @@ -0,0 +1,192 @@ +import { + 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 { + /** + * + */ + data: (AreaPolarSeries | LinePolarSeries | ScatterPolarSeries)[]; + + /** + * + */ + width?: number; + + /** + * + */ + height?: number; + + /** + * + */ + margins?: Margins; + + /** + * @default false + */ + hideLegend?: boolean; + + /** + * @default false + */ + hideTooltip?: boolean; + + /* + * + */ + legendProps?: Partial; + + /** + * + */ + styles?: PolarChartStyles; + + /** + * + */ + chartTitle?: string; + + /** + * + */ + hole?: number; + + /** + * @default 'circle' + */ + shape?: 'circle' | 'polygon'; + + /** + * @default 'counterclockwise' + */ + 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 {} + +/** + * Polar Chart styles + * {@docCategory PolarChart} + */ +export interface PolarChartStyles { + /** + * + */ + root?: string; + + /** + * + */ + chartWrapper?: string; + + /** + * + */ + chart?: string; + + /** + * + */ + 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 new file mode 100644 index 00000000000000..180506a4950705 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/PolarChart/PolarChart.utils.ts @@ -0,0 +1,237 @@ +import { + scaleBand as d3ScaleBand, + scaleLinear as d3ScaleLinear, + scaleLog as d3ScaleLog, + scaleTime as d3ScaleTime, + scaleUtc as d3ScaleUtc, + NumberValue, + 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, +} 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 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; + } = {}, +) => { + 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); + const tickLabels = tickValues.map(tickFormat); + + return { scale, tickValues, tickLabels }; +}; + +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 getScaleDomain = ( + scaleType: string, + values: (number | Date)[], + opts: { + rangeStart?: number | Date; + rangeEnd?: 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 handleDir = (deg: number, direction: 'clockwise' | 'counterclockwise' = 'counterclockwise') => + (((direction === 'clockwise' ? deg : 450 - deg) % 360) + 360) % 360; + +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; + direction?: 'clockwise' | 'counterclockwise'; + } = {}, +): { 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(handleDir(mp[v] * x, 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 formatToLocaleString(domainValue, opts.culture) as string; + }; + if (opts.tickStep) { + customTickValues = generateNumericTicks(scaleType as AxisScaleType, opts.tickStep, opts.tick0, [0, 360 - EPSILON]); + } + const tickValues = customTickValues ?? d3Range(0, 360, 360 / (opts.tickCount ?? 8)); + + return { + scale: (v: number) => degToRad(handleDir(v, opts.direction)), + tickValues, + tickLabels: tickValues.map(tickFormat), + }; +}; 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..bb248bd3bb9f94 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/PolarChart/usePolarChartStyles.styles.ts @@ -0,0 +1,64 @@ +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', +}; + +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 => { + 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/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..5c2bf4555ea8f7 100644 --- a/packages/charts/react-charts/library/src/types/DataPoint.ts +++ b/packages/charts/react-charts/library/src/types/DataPoint.ts @@ -1250,3 +1250,108 @@ export interface LineSeries void; } + +/** + * + */ +export interface PolarDataPoint { + /** + * + */ + r: string | number | Date; + + /** + * + */ + 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 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[], 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 && ( +
+ +
+ )} { + 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: '', + }, + }, + }, +};