Skip to content

Commit 8db966c

Browse files
Anush2303Anush
andauthored
feat(react-charts): Add gantt and funnel in declarative chart (microsoft#35123)
Co-authored-by: Anush <[email protected]>
1 parent d40ec3d commit 8db966c

File tree

4 files changed

+293
-0
lines changed

4 files changed

+293
-0
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Add Gantt and Funnel chart in declarative charts",
4+
"packageName": "@fluentui/react-charts",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import {
3434
getAllupLegendsProps,
3535
NON_PLOT_KEY_PREFIX,
3636
SINGLE_REPEAT,
37+
transformPlotlyJsonToFunnelChartProps,
38+
transformPlotlyJsonToGanttChartProps,
3739
} from './PlotlySchemaAdapter';
3840
import type { ColorwayType } from './PlotlyColorAdapter';
3941
import { DonutChart } from '../DonutChart/index';
@@ -48,6 +50,8 @@ import { GroupedVerticalBarChart } from '../GroupedVerticalBarChart/index';
4850
import { VerticalBarChart } from '../VerticalBarChart/index';
4951
import { Chart, ImageExportOptions } from '../../types/index';
5052
import { ScatterChart } from '../ScatterChart/index';
53+
import { FunnelChart } from '../FunnelChart/FunnelChart';
54+
import { GanttChart } from '../GanttChart/index';
5155

5256
import { withResponsiveContainer } from '../ResponsiveContainer/withResponsiveContainer';
5357
import { ChartTable } from '../ChartTable/index';
@@ -66,6 +70,9 @@ const ResponsiveGroupedVerticalBarChart = withResponsiveContainer(GroupedVertica
6670
const ResponsiveVerticalBarChart = withResponsiveContainer(VerticalBarChart);
6771
const ResponsiveScatterChart = withResponsiveContainer(ScatterChart);
6872
const ResponsiveChartTable = withResponsiveContainer(ChartTable);
73+
const ResponsiveGanttChart = withResponsiveContainer(GanttChart);
74+
// Removing responsive wrapper for FunnelChart as responsive container is not working with FunnelChart
75+
//const ResponsiveFunnelChart = withResponsiveContainer(FunnelChart);
6976

7077
// Default x-axis key for grouping traces. Also applicable for PieData and SankeyData where x-axis is not defined.
7178
const DEFAULT_XAXIS = 'x';
@@ -219,6 +226,14 @@ type ChartTypeMap = {
219226
transformer: typeof transformPlotlyJsonToScatterChartProps;
220227
renderer: typeof ResponsiveScatterChart;
221228
} & PreTransformHooks;
229+
gantt: {
230+
transformer: typeof transformPlotlyJsonToGanttChartProps;
231+
renderer: typeof ResponsiveGanttChart;
232+
} & PreTransformHooks;
233+
funnel: {
234+
transformer: typeof transformPlotlyJsonToFunnelChartProps;
235+
renderer: typeof FunnelChart;
236+
} & PreTransformHooks;
222237
fallback: {
223238
transformer: typeof transformPlotlyJsonToVSBCProps;
224239
renderer: typeof ResponsiveVerticalStackedBarChart;
@@ -281,6 +296,14 @@ const chartMap: ChartTypeMap = {
281296
renderer: ResponsiveScatterChart,
282297
preTransformOperation: LineAreaPreTransformOp,
283298
},
299+
gantt: {
300+
transformer: transformPlotlyJsonToGanttChartProps,
301+
renderer: ResponsiveGanttChart,
302+
},
303+
funnel: {
304+
transformer: transformPlotlyJsonToFunnelChartProps,
305+
renderer: FunnelChart,
306+
},
284307
fallback: {
285308
transformer: transformPlotlyJsonToVSBCProps,
286309
renderer: ResponsiveVerticalStackedBarChart,

packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
VerticalBarChartDataPoint,
2727
SankeyChartData,
2828
LineChartLineOptions,
29+
GanttChartDataPoint,
2930
} from '../../types/DataPoint';
3031
import { SankeyChartProps } from '../SankeyChart/index';
3132
import { VerticalStackedBarChartProps } from '../VerticalStackedBarChart/index';
@@ -86,6 +87,7 @@ import { rgb } from 'd3-color';
8687
import { Legend, LegendsProps } from '../Legends/index';
8788
import { ScatterChartProps } from '../ScatterChart/ScatterChart.types';
8889
import { CartesianChartProps } from '../CommonComponents/index';
90+
import { FunnelChartDataPoint, FunnelChartProps } from '../FunnelChart/FunnelChart.types';
8991

9092
export const NON_PLOT_KEY_PREFIX = 'nonplot_';
9193
export const SINGLE_REPEAT = 'repeat(1, 1fr)';
@@ -1210,6 +1212,83 @@ export const transformPlotlyJsonToHorizontalBarWithAxisProps = (
12101212
};
12111213
};
12121214

1215+
export const transformPlotlyJsonToGanttChartProps = (
1216+
input: PlotlySchema,
1217+
isMultiPlot: boolean,
1218+
colorMap: React.MutableRefObject<Map<string, string>>,
1219+
colorwayType: ColorwayType,
1220+
isDarkTheme?: boolean,
1221+
): GanttChartProps => {
1222+
const { legends, hideLegend } = getLegendProps(input.data, input.layout, isMultiPlot);
1223+
let colorScale: ((value: number) => string) | undefined = undefined;
1224+
const chartData: GanttChartDataPoint[] = input.data
1225+
.map((series: Partial<PlotData>, index: number) => {
1226+
colorScale = createColorScale(input.layout, series, colorScale);
1227+
1228+
// extract colors for each series only once
1229+
const extractedColors = extractColor(
1230+
input.layout?.template?.layout?.colorway,
1231+
colorwayType,
1232+
series.marker?.color,
1233+
colorMap,
1234+
isDarkTheme,
1235+
) as string[] | string | undefined;
1236+
const legend = legends[index];
1237+
const isXDate = input.layout?.xaxis?.type === 'date' || isDateArray(series.x);
1238+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1239+
const convertXValueToNumber = (value: any) => {
1240+
return isInvalidValue(value) ? 0 : isXDate ? +parseLocalDate(value) : +value;
1241+
};
1242+
1243+
return (series.y as Datum[])
1244+
.map((yVal, i: number) => {
1245+
if (isInvalidValue(yVal)) {
1246+
return null;
1247+
}
1248+
// resolve color for each legend's bars from the colorscale or extracted colors
1249+
const color = colorScale
1250+
? colorScale(
1251+
isArrayOrTypedArray(series.marker?.color)
1252+
? ((series.marker?.color as Color[])?.[i % (series.marker?.color as Color[]).length] as number)
1253+
: 0,
1254+
)
1255+
: resolveColor(extractedColors, i, legend, colorMap, isDarkTheme);
1256+
const opacity = getOpacity(series, i);
1257+
const base = convertXValueToNumber(series.base?.[i]);
1258+
const xVal = convertXValueToNumber(series.x?.[i]);
1259+
1260+
return {
1261+
x: {
1262+
start: isXDate ? new Date(base) : base,
1263+
end: isXDate ? new Date(base + xVal) : base + xVal,
1264+
},
1265+
y: yVal,
1266+
legend,
1267+
color: rgb(color).copy({ opacity }).formatHex8() ?? color,
1268+
} as GanttChartDataPoint;
1269+
})
1270+
.filter(point => point !== null) as GanttChartDataPoint[];
1271+
})
1272+
.flat();
1273+
1274+
return {
1275+
data: chartData,
1276+
showYAxisLables: true,
1277+
height: input.layout?.height ?? 350,
1278+
width: input.layout?.width,
1279+
hideTickOverlap: true,
1280+
hideLegend,
1281+
noOfCharsToTruncate: 20,
1282+
showYAxisLablesTooltip: true,
1283+
roundCorners: true,
1284+
useUTC: false,
1285+
...getTitles(input.layout),
1286+
...getAxisCategoryOrderProps(input.data, input.layout),
1287+
...getBarProps(input.data, input.layout, true),
1288+
...getAxisTickProps(input.data, input.layout),
1289+
};
1290+
};
1291+
12131292
export const transformPlotlyJsonToHeatmapProps = (
12141293
input: PlotlySchema,
12151294
isMultiPlot: boolean,
@@ -1731,6 +1810,148 @@ export const transformPlotlyJsonToChartTableProps = (
17311810
};
17321811
};
17331812

1813+
function getCategoriesAndValues(series: Partial<PlotData>): {
1814+
categories: (string | number)[];
1815+
values: (string | number)[];
1816+
} {
1817+
const orientation = series.orientation || 'h';
1818+
const y = series.labels ?? series.y ?? series.stage;
1819+
const x = series.values ?? series.x ?? series.value;
1820+
const xIsString = isStringArray(x as Datum[] | Datum[][] | TypedArray | undefined);
1821+
const yIsString = isStringArray(y as Datum[] | Datum[][] | TypedArray | undefined);
1822+
const xIsNumber = isNumberArray(x as Datum[] | Datum[][] | TypedArray | undefined);
1823+
const yIsNumber = isNumberArray(y as Datum[] | Datum[][] | TypedArray | undefined);
1824+
1825+
// Helper to ensure array of (string | number)
1826+
const toArray = (arr: unknown): (string | number)[] => {
1827+
if (Array.isArray(arr)) {
1828+
return arr as (string | number)[];
1829+
}
1830+
if (typeof arr === 'string' || typeof arr === 'number') {
1831+
return [arr];
1832+
}
1833+
return [];
1834+
};
1835+
1836+
if (orientation === 'h') {
1837+
if (yIsString && xIsNumber) {
1838+
return { categories: toArray(y), values: toArray(x) };
1839+
} else if (xIsString && yIsNumber) {
1840+
return { categories: toArray(x), values: toArray(y) };
1841+
} else {
1842+
return { categories: yIsString ? toArray(y) : toArray(x), values: yIsString ? toArray(x) : toArray(y) };
1843+
}
1844+
} else {
1845+
if (xIsString && yIsNumber) {
1846+
return { categories: toArray(x), values: toArray(y) };
1847+
} else if (yIsString && xIsNumber) {
1848+
return { categories: toArray(y), values: toArray(x) };
1849+
} else {
1850+
return { categories: xIsString ? toArray(x) : toArray(y), values: xIsString ? toArray(y) : toArray(x) };
1851+
}
1852+
}
1853+
}
1854+
1855+
export const transformPlotlyJsonToFunnelChartProps = (
1856+
input: PlotlySchema,
1857+
isMultiPlot: boolean,
1858+
colorMap: React.MutableRefObject<Map<string, string>>,
1859+
colorwayType: ColorwayType,
1860+
isDarkTheme?: boolean,
1861+
): FunnelChartProps => {
1862+
const funnelData: FunnelChartDataPoint[] = [];
1863+
1864+
// Determine if data is stacked based on multiple series with multiple values per series
1865+
const isStacked =
1866+
input.data.length > 1 &&
1867+
input.data.every((series: Partial<PlotData>) => {
1868+
const values = series.values ?? series.x ?? series.value;
1869+
const labels = series.labels ?? series.y ?? series.stage;
1870+
return Array.isArray(labels) && Array.isArray(values) && values.length > 1 && labels.length > 1;
1871+
});
1872+
1873+
if (isStacked) {
1874+
// Assign a color per series/category and use it for all subValues of that category
1875+
const seriesColors: Record<string, string> = {};
1876+
input.data.forEach((series: Partial<PlotData>, seriesIdx: number) => {
1877+
const category = series.name || `Category ${seriesIdx + 1}`;
1878+
// Use the same color for this category across all stages
1879+
const extractedColors = extractColor(
1880+
input.layout?.template?.layout?.colorway,
1881+
colorwayType,
1882+
series.marker?.colors ?? series.marker?.color,
1883+
colorMap,
1884+
isDarkTheme,
1885+
);
1886+
// Always use the first color for the series/category
1887+
const color = resolveColor(extractedColors, 0, category, colorMap, isDarkTheme);
1888+
seriesColors[category] = color;
1889+
1890+
const labels = series.labels ?? series.y ?? series.stage;
1891+
const values = series.values ?? series.x ?? series.value;
1892+
1893+
if (!isArrayOrTypedArray(labels) || !isArrayOrTypedArray(values)) {
1894+
return;
1895+
}
1896+
if (labels && isArrayOrTypedArray(labels) && labels.length > 0) {
1897+
(labels as (string | number)[]).forEach((label: string, i: number) => {
1898+
const stageIndex = funnelData.findIndex(stage => stage.stage === label);
1899+
const valueNum = Number((values as (string | number)[])[i]);
1900+
if (isNaN(valueNum)) {
1901+
return;
1902+
}
1903+
if (stageIndex === -1) {
1904+
funnelData.push({
1905+
stage: label,
1906+
subValues: [{ category, value: valueNum, color }],
1907+
});
1908+
} else {
1909+
funnelData[stageIndex].subValues!.push({ category, value: valueNum, color });
1910+
}
1911+
});
1912+
}
1913+
});
1914+
} else {
1915+
// Non-stacked data handling (multiple series with single-value arrays)
1916+
input.data.forEach((series: Partial<PlotData>, seriesIdx: number) => {
1917+
const { categories, values } = getCategoriesAndValues(series);
1918+
1919+
if (!isArrayOrTypedArray(categories) || !isArrayOrTypedArray(values)) {
1920+
return;
1921+
}
1922+
1923+
const extractedColors = extractColor(
1924+
input.layout?.template?.layout?.colorway,
1925+
colorwayType,
1926+
series.marker?.colors ?? series.marker?.color,
1927+
colorMap,
1928+
isDarkTheme,
1929+
);
1930+
1931+
categories.forEach((label: string, i: number) => {
1932+
const color = resolveColor(extractedColors, i, label, colorMap, isDarkTheme);
1933+
const valueNum = Number(values[i]);
1934+
if (isNaN(valueNum)) {
1935+
return;
1936+
}
1937+
funnelData.push({
1938+
stage: label,
1939+
value: valueNum,
1940+
color,
1941+
});
1942+
});
1943+
});
1944+
}
1945+
1946+
return {
1947+
data: funnelData,
1948+
width: input.layout?.width,
1949+
height: input.layout?.height,
1950+
orientation: (input.data[0] as Partial<PlotData>)?.orientation === 'v' ? 'horizontal' : 'vertical',
1951+
hideLegend: isMultiPlot || input.layout?.showlegend === false,
1952+
};
1953+
};
1954+
17341955
export const projectPolarToCartesian = (input: PlotlySchema): PlotlySchema => {
17351956
const projection: PlotlySchema = { ...input };
17361957

@@ -2711,3 +2932,31 @@ const getAxisType = (data: Data[], axLetter: 'x' | 'y', ax: Partial<LayoutAxis>
27112932
return 'category';
27122933
}
27132934
};
2935+
2936+
/**
2937+
* This is experimental. Use it only with valid datetime strings to verify if they conform to the ISO 8601 format.
2938+
*/
2939+
const isoDateRegex = /^\d{4}(-\d{2}(-\d{2})?)?(T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z)?)?$/;
2940+
2941+
/**
2942+
* We want to display localized date and time in the charts, so the useUTC prop is set to false.
2943+
* But this can sometimes cause the formatters to display the datetime incorrectly.
2944+
* To work around this issue, we use this function to adjust datetime strings so that they are always interpreted
2945+
* as local time, allowing the formatters to produce the correct output.
2946+
*
2947+
* FIXME: The formatters should always produce a clear and accurate localized output, regardless of the
2948+
* format used to create the date object.
2949+
*/
2950+
const parseLocalDate = (value: string | number) => {
2951+
if (typeof value === 'string') {
2952+
const match = value.match(isoDateRegex);
2953+
if (match) {
2954+
if (!match[3]) {
2955+
value += 'T00:00';
2956+
} else if (match[6]) {
2957+
value = value.replace('Z', '');
2958+
}
2959+
}
2960+
}
2961+
return new Date(value);
2962+
};

0 commit comments

Comments
 (0)