From 5789fb7fe336e9c93061a87c9e297ec3e58f8eeb Mon Sep 17 00:00:00 2001 From: Amrit Kashyap Borah Date: Wed, 13 Aug 2025 15:00:16 +0530 Subject: [PATCH 1/7] feat: add Chart component --- package-lock.json | 19 ++ package.json | 1 + .../Components/Charts/Chart.component.tsx | 85 +++++++ src/Shared/Components/Charts/index.ts | 2 + src/Shared/Components/Charts/types.ts | 15 ++ src/Shared/Components/Charts/utils.ts | 221 ++++++++++++++++++ src/Shared/Components/index.ts | 1 + 7 files changed, 344 insertions(+) create mode 100644 src/Shared/Components/Charts/Chart.component.tsx create mode 100644 src/Shared/Components/Charts/index.ts create mode 100644 src/Shared/Components/Charts/types.ts create mode 100644 src/Shared/Components/Charts/utils.ts diff --git a/package-lock.json b/package-lock.json index 70f1111b6..6444700d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@uiw/react-codemirror": "4.23.7", "@xyflow/react": "12.4.2", "ansi_up": "^5.2.1", + "chart.js": "^4.5.0", "codemirror-json-schema": "0.8.0", "dayjs": "^1.11.13", "fast-json-patch": "^3.1.1", @@ -2037,6 +2038,12 @@ "jsep": "^0.4.0||^1.0.0" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@laynezh/vite-plugin-lib-assets": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@laynezh/vite-plugin-lib-assets/-/vite-plugin-lib-assets-1.1.0.tgz", @@ -5297,6 +5304,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chart.js": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", diff --git a/package.json b/package.json index 3740eefd6..5f9c560c4 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "@uiw/react-codemirror": "4.23.7", "@xyflow/react": "12.4.2", "ansi_up": "^5.2.1", + "chart.js": "^4.5.0", "codemirror-json-schema": "0.8.0", "dayjs": "^1.11.13", "fast-json-patch": "^3.1.1", diff --git a/src/Shared/Components/Charts/Chart.component.tsx b/src/Shared/Components/Charts/Chart.component.tsx new file mode 100644 index 000000000..3eaf78e90 --- /dev/null +++ b/src/Shared/Components/Charts/Chart.component.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef } from 'react' +import { + ArcElement, + BarController, + BarElement, + CategoryScale, + Chart as ChartJS, + DoughnutController, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Title, + Tooltip, +} from 'chart.js' + +import { ChartProps } from './types' +import { getChartJSType, getDefaultOptions, transformDataForChart } from './utils' + +// Register Chart.js components +ChartJS.register( + CategoryScale, + LinearScale, + BarController, + BarElement, + LineController, + LineElement, + PointElement, + DoughnutController, + ArcElement, + Title, + Tooltip, + Legend, + Filler, +) + +const Chart = ({ id, type, labels, datasets, className, style }: ChartProps) => { + const canvasRef = useRef(null) + const chartRef = useRef(null) + + useEffect(() => { + if (!canvasRef.current) return + + const ctx = canvasRef.current.getContext('2d') + if (!ctx) return + + // Destroy existing chart if it exists + if (chartRef.current) { + chartRef.current.destroy() + } + + // Get Chart.js type and transform data + const chartJSType = getChartJSType(type) + const transformedData = transformDataForChart(labels, datasets, type) + const defaultOptions = getDefaultOptions(type) + + // Create new chart + chartRef.current = new ChartJS(ctx, { + type: chartJSType, + data: transformedData, + options: defaultOptions, + }) + }, []) + + // Cleanup on unmount + useEffect( + () => () => { + if (chartRef.current) { + chartRef.current.destroy() + chartRef.current = null + } + }, + [], + ) + + return ( +
+ +
+ ) +} + +export default Chart diff --git a/src/Shared/Components/Charts/index.ts b/src/Shared/Components/Charts/index.ts new file mode 100644 index 000000000..971e5ae16 --- /dev/null +++ b/src/Shared/Components/Charts/index.ts @@ -0,0 +1,2 @@ +export { default as Chart } from './Chart.component' +export type { ChartProps, SimpleDataset } from './types' diff --git a/src/Shared/Components/Charts/types.ts b/src/Shared/Components/Charts/types.ts new file mode 100644 index 000000000..375599cb8 --- /dev/null +++ b/src/Shared/Components/Charts/types.ts @@ -0,0 +1,15 @@ +export type ChartType = 'bar' | 'line' | 'area' | 'pie' | 'horizontalBar' | 'stackedBar' + +export interface SimpleDataset { + label: string + data: number[] +} + +export interface ChartProps { + id: string + type: ChartType + labels: string[] + datasets: SimpleDataset[] + className?: string + style?: React.CSSProperties +} diff --git a/src/Shared/Components/Charts/utils.ts b/src/Shared/Components/Charts/utils.ts new file mode 100644 index 000000000..cf61d818d --- /dev/null +++ b/src/Shared/Components/Charts/utils.ts @@ -0,0 +1,221 @@ +import { ChartData, ChartOptions, ChartType as ChartJSChartType } from 'chart.js' + +import { ChartType, SimpleDataset } from './types' + +const getCSSVariableValue = (variableName: string) => { + const value = getComputedStyle(document.querySelector('#devtron-base-main-identifier')).getPropertyValue( + variableName, + ) + + if (!value) { + // eslint-disable-next-line no-console + console.error(`CSS variable "${variableName}" not found`) + } + + return value ?? 'rgba(0, 0, 0, 0.1)' +} + +// Map our chart types to Chart.js types +export const getChartJSType = (type: ChartType): ChartJSChartType => { + switch (type) { + case 'area': + return 'line' + case 'pie': + return 'doughnut' + case 'horizontalBar': + case 'stackedBar': + return 'bar' + default: + return type as ChartJSChartType + } +} + +// Get default options based on chart type +export const getDefaultOptions = (type: ChartType): ChartOptions => { + const baseOptions: ChartOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom' as const, + }, + title: { + display: false, + }, + }, + } + + switch (type) { + case 'area': + return { + ...baseOptions, + elements: { + line: { + fill: true, + }, + }, + scales: { + y: { + beginAtZero: true, + }, + }, + } + case 'line': + return { + ...baseOptions, + scales: { + x: { + grid: { + color: getCSSVariableValue('--N50'), + }, + }, + y: { + beginAtZero: true, + grid: { + color: getCSSVariableValue('--N50'), + }, + }, + }, + } + case 'bar': + return { + ...baseOptions, + scales: { + x: { + grid: { + color: getCSSVariableValue('--N50'), + }, + }, + y: { + beginAtZero: true, + grid: { + color: getCSSVariableValue('--N50'), + }, + }, + }, + } + case 'stackedBar': + return { + ...baseOptions, + scales: { + x: { + stacked: true, + grid: { + color: getCSSVariableValue('--N50'), + }, + }, + y: { + stacked: true, + beginAtZero: true, + grid: { + color: getCSSVariableValue('--N50'), + }, + }, + }, + } + case 'horizontalBar': + return { + ...baseOptions, + indexAxis: 'y' as const, + scales: { + x: { + beginAtZero: true, + grid: { + color: getCSSVariableValue('--N50'), + }, + }, + }, + } + case 'pie': + return { + ...baseOptions, + plugins: { + ...baseOptions.plugins, + legend: { + position: 'right' as const, + }, + }, + } + default: + return baseOptions + } +} + +// Define color palette for consistent styling +const getColorPalette = () => [ + 'rgba(54, 162, 235, 0.8)', // Blue + 'rgba(255, 99, 132, 0.8)', // Red + 'rgba(255, 205, 86, 0.8)', // Yellow + 'rgba(75, 192, 192, 0.8)', // Green + 'rgba(153, 102, 255, 0.8)', // Purple + 'rgba(255, 159, 64, 0.8)', // Orange + 'rgba(199, 199, 199, 0.8)', // Grey + 'rgba(83, 102, 255, 0.8)', // Indigo +] + +const getBorderColorPalette = () => [ + 'rgba(54, 162, 235, 1)', // Blue + 'rgba(255, 99, 132, 1)', // Red + 'rgba(255, 205, 86, 1)', // Yellow + 'rgba(75, 192, 192, 1)', // Green + 'rgba(153, 102, 255, 1)', // Purple + 'rgba(255, 159, 64, 1)', // Orange + 'rgba(199, 199, 199, 1)', // Grey + 'rgba(83, 102, 255, 1)', // Indigo +] + +// Transform simple data to Chart.js format with consistent styling +export const transformDataForChart = (labels: string[], datasets: SimpleDataset[], type: ChartType): ChartData => { + const colors = getColorPalette() + const borderColors = getBorderColorPalette() + + const transformedDatasets = datasets.map((dataset, index) => { + const colorIndex = index % colors.length + const baseDataset = { + label: dataset.label, + data: dataset.data, + backgroundColor: colors[colorIndex], + borderColor: borderColors[colorIndex], + borderWidth: 2, + } + + switch (type) { + case 'area': + return { + ...baseDataset, + fill: true, + tension: 0.4, + backgroundColor: colors[colorIndex].replace('0.8', '0.2'), + } + case 'line': + return { + ...baseDataset, + fill: false, + tension: 0.1, + pointRadius: 4, + pointHoverRadius: 6, + } + case 'pie': + return { + ...baseDataset, + backgroundColor: colors.slice(0, dataset.data.length), + borderColor: borderColors.slice(0, dataset.data.length), + borderWidth: 1, + } + case 'bar': + case 'stackedBar': + case 'horizontalBar': + return { + ...baseDataset, + borderRadius: 4, + } + default: + return baseDataset + } + }) + + return { + labels, + datasets: transformedDatasets, + } +} diff --git a/src/Shared/Components/index.ts b/src/Shared/Components/index.ts index 0cd4af28e..06357d255 100644 --- a/src/Shared/Components/index.ts +++ b/src/Shared/Components/index.ts @@ -29,6 +29,7 @@ export * from './BulkSelection' export * from './Button' export * from './ButtonWithLoader' export * from './ButtonWithSelector' +export * from './Charts' export * from './Chip' export * from './CICDHistory' export * from './CMCS' From 4f3d6d2806931630f14429d99aa3399d4c5d227a Mon Sep 17 00:00:00 2001 From: Amrit Kashyap Borah Date: Mon, 18 Aug 2025 10:32:38 +0530 Subject: [PATCH 2/7] refactor: chart types --- .../Components/Charts/Chart.component.tsx | 2 +- src/Shared/Components/Charts/types.ts | 2 +- src/Shared/Components/Charts/utils.ts | 86 +++++++------------ 3 files changed, 35 insertions(+), 55 deletions(-) diff --git a/src/Shared/Components/Charts/Chart.component.tsx b/src/Shared/Components/Charts/Chart.component.tsx index 3eaf78e90..316d26b3f 100644 --- a/src/Shared/Components/Charts/Chart.component.tsx +++ b/src/Shared/Components/Charts/Chart.component.tsx @@ -62,7 +62,7 @@ const Chart = ({ id, type, labels, datasets, className, style }: ChartProps) => data: transformedData, options: defaultOptions, }) - }, []) + }, [type, datasets, labels]) // Cleanup on unmount useEffect( diff --git a/src/Shared/Components/Charts/types.ts b/src/Shared/Components/Charts/types.ts index 375599cb8..1f1acf515 100644 --- a/src/Shared/Components/Charts/types.ts +++ b/src/Shared/Components/Charts/types.ts @@ -1,4 +1,4 @@ -export type ChartType = 'bar' | 'line' | 'area' | 'pie' | 'horizontalBar' | 'stackedBar' +export type ChartType = 'area' | 'pie' | 'stackedBar' | 'stackedBarHorizontal' export interface SimpleDataset { label: string diff --git a/src/Shared/Components/Charts/utils.ts b/src/Shared/Components/Charts/utils.ts index cf61d818d..9d93e1a2c 100644 --- a/src/Shared/Components/Charts/utils.ts +++ b/src/Shared/Components/Charts/utils.ts @@ -22,8 +22,8 @@ export const getChartJSType = (type: ChartType): ChartJSChartType => { return 'line' case 'pie': return 'doughnut' - case 'horizontalBar': case 'stackedBar': + case 'stackedBarHorizontal': return 'bar' default: return type as ChartJSChartType @@ -57,72 +57,71 @@ export const getDefaultOptions = (type: ChartType): ChartOptions => { scales: { y: { beginAtZero: true, - }, - }, - } - case 'line': - return { - ...baseOptions, - scales: { - x: { grid: { color: getCSSVariableValue('--N50'), }, }, - y: { - beginAtZero: true, + x: { grid: { color: getCSSVariableValue('--N50'), }, }, }, } - case 'bar': + case 'stackedBar': return { ...baseOptions, scales: { x: { + stacked: true, grid: { color: getCSSVariableValue('--N50'), }, }, y: { + stacked: true, beginAtZero: true, grid: { color: getCSSVariableValue('--N50'), }, }, }, + elements: { + bar: { + // Add gap between bars + borderSkipped: 'start', + borderWidth: 2, + borderColor: 'transparent', + borderRadius: 4, + }, + }, } - case 'stackedBar': + case 'stackedBarHorizontal': return { ...baseOptions, + indexAxis: 'y' as const, scales: { x: { stacked: true, + beginAtZero: true, grid: { color: getCSSVariableValue('--N50'), }, }, y: { stacked: true, - beginAtZero: true, grid: { color: getCSSVariableValue('--N50'), }, }, }, - } - case 'horizontalBar': - return { - ...baseOptions, - indexAxis: 'y' as const, - scales: { - x: { - beginAtZero: true, - grid: { - color: getCSSVariableValue('--N50'), - }, + elements: { + bar: { + // Add gap between bars + borderSkipped: 'start', + borderWidth: 2, + borderColor: 'transparent', + borderRadius: 4, }, }, } @@ -132,7 +131,13 @@ export const getDefaultOptions = (type: ChartType): ChartOptions => { plugins: { ...baseOptions.plugins, legend: { - position: 'right' as const, + position: 'right', + align: 'center', + }, + }, + elements: { + arc: { + spacing: 2, }, }, } @@ -153,21 +158,9 @@ const getColorPalette = () => [ 'rgba(83, 102, 255, 0.8)', // Indigo ] -const getBorderColorPalette = () => [ - 'rgba(54, 162, 235, 1)', // Blue - 'rgba(255, 99, 132, 1)', // Red - 'rgba(255, 205, 86, 1)', // Yellow - 'rgba(75, 192, 192, 1)', // Green - 'rgba(153, 102, 255, 1)', // Purple - 'rgba(255, 159, 64, 1)', // Orange - 'rgba(199, 199, 199, 1)', // Grey - 'rgba(83, 102, 255, 1)', // Indigo -] - // Transform simple data to Chart.js format with consistent styling export const transformDataForChart = (labels: string[], datasets: SimpleDataset[], type: ChartType): ChartData => { const colors = getColorPalette() - const borderColors = getBorderColorPalette() const transformedDatasets = datasets.map((dataset, index) => { const colorIndex = index % colors.length @@ -175,8 +168,6 @@ export const transformDataForChart = (labels: string[], datasets: SimpleDataset[ label: dataset.label, data: dataset.data, backgroundColor: colors[colorIndex], - borderColor: borderColors[colorIndex], - borderWidth: 2, } switch (type) { @@ -185,29 +176,18 @@ export const transformDataForChart = (labels: string[], datasets: SimpleDataset[ ...baseDataset, fill: true, tension: 0.4, + pointRadius: 0, backgroundColor: colors[colorIndex].replace('0.8', '0.2'), } - case 'line': - return { - ...baseDataset, - fill: false, - tension: 0.1, - pointRadius: 4, - pointHoverRadius: 6, - } case 'pie': return { ...baseDataset, backgroundColor: colors.slice(0, dataset.data.length), - borderColor: borderColors.slice(0, dataset.data.length), - borderWidth: 1, } - case 'bar': case 'stackedBar': - case 'horizontalBar': + case 'stackedBarHorizontal': return { ...baseDataset, - borderRadius: 4, } default: return baseDataset From f7642c77351d095dbf108b6237cd63fa06078b5b Mon Sep 17 00:00:00 2001 From: Amrit Kashyap Borah Date: Mon, 18 Aug 2025 18:38:13 +0530 Subject: [PATCH 3/7] refactor: chart & colors --- .../Components/Charts/Chart.component.tsx | 29 ++-- src/Shared/Components/Charts/constants.ts | 11 ++ src/Shared/Components/Charts/utils.ts | 137 +++++++++--------- 3 files changed, 93 insertions(+), 84 deletions(-) create mode 100644 src/Shared/Components/Charts/constants.ts diff --git a/src/Shared/Components/Charts/Chart.component.tsx b/src/Shared/Components/Charts/Chart.component.tsx index 316d26b3f..023850dc9 100644 --- a/src/Shared/Components/Charts/Chart.component.tsx +++ b/src/Shared/Components/Charts/Chart.component.tsx @@ -16,6 +16,7 @@ import { Tooltip, } from 'chart.js' +import { LEGENDS_LABEL_CONFIG } from './constants' import { ChartProps } from './types' import { getChartJSType, getDefaultOptions, transformDataForChart } from './utils' @@ -36,20 +37,17 @@ ChartJS.register( Filler, ) +ChartJS.overrides.doughnut.plugins.legend.labels = { + ...ChartJS.overrides.doughnut.plugins.legend.labels, + ...LEGENDS_LABEL_CONFIG, +} + const Chart = ({ id, type, labels, datasets, className, style }: ChartProps) => { const canvasRef = useRef(null) const chartRef = useRef(null) useEffect(() => { - if (!canvasRef.current) return - const ctx = canvasRef.current.getContext('2d') - if (!ctx) return - - // Destroy existing chart if it exists - if (chartRef.current) { - chartRef.current.destroy() - } // Get Chart.js type and transform data const chartJSType = getChartJSType(type) @@ -62,18 +60,11 @@ const Chart = ({ id, type, labels, datasets, className, style }: ChartProps) => data: transformedData, options: defaultOptions, }) - }, [type, datasets, labels]) - // Cleanup on unmount - useEffect( - () => () => { - if (chartRef.current) { - chartRef.current.destroy() - chartRef.current = null - } - }, - [], - ) + return () => { + chartRef.current?.destroy() + } + }, [type, datasets, labels]) return (
diff --git a/src/Shared/Components/Charts/constants.ts b/src/Shared/Components/Charts/constants.ts new file mode 100644 index 000000000..ec22128fe --- /dev/null +++ b/src/Shared/Components/Charts/constants.ts @@ -0,0 +1,11 @@ +export const LEGENDS_LABEL_CONFIG = { + usePointStyle: true, + pointStyle: 'rectRounded', + pointStyleWidth: 0, + font: { + family: "'IBM Plex Sans', 'Open Sans', 'Roboto'", + size: 13, + lineHeight: '150%', + weight: 400, + }, +} as const diff --git a/src/Shared/Components/Charts/utils.ts b/src/Shared/Components/Charts/utils.ts index 9d93e1a2c..8f3344ebf 100644 --- a/src/Shared/Components/Charts/utils.ts +++ b/src/Shared/Components/Charts/utils.ts @@ -1,5 +1,6 @@ -import { ChartData, ChartOptions, ChartType as ChartJSChartType } from 'chart.js' +import { ChartData, ChartDataset, ChartOptions, ChartType as ChartJSChartType } from 'chart.js' +import { LEGENDS_LABEL_CONFIG } from './constants' import { ChartType, SimpleDataset } from './types' const getCSSVariableValue = (variableName: string) => { @@ -12,7 +13,7 @@ const getCSSVariableValue = (variableName: string) => { console.error(`CSS variable "${variableName}" not found`) } - return value ?? 'rgba(0, 0, 0, 0.1)' + return value ?? 'transparent' } // Map our chart types to Chart.js types @@ -35,64 +36,75 @@ export const getDefaultOptions = (type: ChartType): ChartOptions => { const baseOptions: ChartOptions = { responsive: true, maintainAspectRatio: false, + devicePixelRatio: 3, plugins: { legend: { position: 'bottom' as const, + labels: LEGENDS_LABEL_CONFIG, }, title: { display: false, }, }, + elements: { + line: { + fill: true, + tension: 0.4, + }, + bar: { + borderSkipped: 'start' as const, + borderWidth: 2, + borderColor: 'transparent', + borderRadius: 4, + }, + arc: { + spacing: 2, + }, + }, + } + + const gridConfig = { + color: getCSSVariableValue('--N50'), } switch (type) { case 'area': return { ...baseOptions, - elements: { - line: { - fill: true, + plugins: { + ...baseOptions.plugins, + tooltip: { + mode: 'index', }, }, + interaction: { + mode: 'nearest', + axis: 'x', + intersect: false, + }, scales: { y: { + stacked: true, beginAtZero: true, - grid: { - color: getCSSVariableValue('--N50'), - }, + grid: gridConfig, }, x: { - grid: { - color: getCSSVariableValue('--N50'), - }, + grid: gridConfig, }, }, - } + } as ChartOptions<'line'> case 'stackedBar': return { ...baseOptions, scales: { x: { stacked: true, - grid: { - color: getCSSVariableValue('--N50'), - }, + grid: gridConfig, }, y: { stacked: true, beginAtZero: true, - grid: { - color: getCSSVariableValue('--N50'), - }, - }, - }, - elements: { - bar: { - // Add gap between bars - borderSkipped: 'start', - borderWidth: 2, - borderColor: 'transparent', - borderRadius: 4, + grid: gridConfig, }, }, } @@ -104,24 +116,11 @@ export const getDefaultOptions = (type: ChartType): ChartOptions => { x: { stacked: true, beginAtZero: true, - grid: { - color: getCSSVariableValue('--N50'), - }, + grid: gridConfig, }, y: { stacked: true, - grid: { - color: getCSSVariableValue('--N50'), - }, - }, - }, - elements: { - bar: { - // Add gap between bars - borderSkipped: 'start', - borderWidth: 2, - borderColor: 'transparent', - borderRadius: 4, + grid: gridConfig, }, }, } @@ -135,32 +134,39 @@ export const getDefaultOptions = (type: ChartType): ChartOptions => { align: 'center', }, }, - elements: { - arc: { - spacing: 2, - }, - }, } default: return baseOptions } } -// Define color palette for consistent styling -const getColorPalette = () => [ - 'rgba(54, 162, 235, 0.8)', // Blue - 'rgba(255, 99, 132, 0.8)', // Red - 'rgba(255, 205, 86, 0.8)', // Yellow - 'rgba(75, 192, 192, 0.8)', // Green - 'rgba(153, 102, 255, 0.8)', // Purple - 'rgba(255, 159, 64, 0.8)', // Orange - 'rgba(199, 199, 199, 0.8)', // Grey - 'rgba(83, 102, 255, 0.8)', // Indigo -] +// Generates a palette of pastel HSL colors +const generateColors = (count: number): string[] => { + const colors: string[] = [] + for (let i = 0; i < count; i++) { + const hue = (i * 360) / count + const saturation = 50 // Pastel: 40-60% + const lightness = 75 // Pastel: 80-90% + colors.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`) + } + return colors +} + +// Generates a slightly darker shade for a given HSL color string +const generateCorrespondingBorderColor = (hsl: string): string => { + // Parse hsl string: hsl(hue, saturation%, lightness%) + const match = hsl.match(/hsl\((\d+),\s*([\d.]+)%,\s*([\d.]+)%\)/) + if (!match) throw new Error('Invalid HSL color format') + const hue = Number(match[1]) + const saturation = Number(match[2]) + let lightness = Number(match[3]) + lightness = Math.max(0, lightness - 15) // Clamp to 0 + return `hsl(${hue}, ${saturation}%, ${lightness}%)` +} // Transform simple data to Chart.js format with consistent styling export const transformDataForChart = (labels: string[], datasets: SimpleDataset[], type: ChartType): ChartData => { - const colors = getColorPalette() + const colors = generateColors(type === 'pie' ? datasets[0].data.length : datasets.length) const transformedDatasets = datasets.map((dataset, index) => { const colorIndex = index % colors.length @@ -175,10 +181,14 @@ export const transformDataForChart = (labels: string[], datasets: SimpleDataset[ return { ...baseDataset, fill: true, - tension: 0.4, pointRadius: 0, - backgroundColor: colors[colorIndex].replace('0.8', '0.2'), - } + pointHoverRadius: 10, + pointHitRadius: 20, + pointStyle: 'rectRounded', + pointBorderWidth: 0, + borderWidth: 2, + borderColor: generateCorrespondingBorderColor(colors[colorIndex]), + } as ChartDataset<'line'> case 'pie': return { ...baseDataset, @@ -186,9 +196,6 @@ export const transformDataForChart = (labels: string[], datasets: SimpleDataset[ } case 'stackedBar': case 'stackedBarHorizontal': - return { - ...baseDataset, - } default: return baseDataset } From a9ce9a6468245c4b014ad525398bb57506c1fefe Mon Sep 17 00:00:00 2001 From: Amrit Kashyap Borah Date: Wed, 20 Aug 2025 13:29:03 +0530 Subject: [PATCH 4/7] feat: add line chart --- .../Components/Charts/Chart.component.tsx | 30 ++- src/Shared/Components/Charts/constants.ts | 134 ++++++++++++ src/Shared/Components/Charts/index.ts | 2 +- src/Shared/Components/Charts/types.ts | 57 +++++- src/Shared/Components/Charts/utils.ts | 192 ++++++++++-------- 5 files changed, 315 insertions(+), 100 deletions(-) diff --git a/src/Shared/Components/Charts/Chart.component.tsx b/src/Shared/Components/Charts/Chart.component.tsx index 023850dc9..47bea6771 100644 --- a/src/Shared/Components/Charts/Chart.component.tsx +++ b/src/Shared/Components/Charts/Chart.component.tsx @@ -16,6 +16,9 @@ import { Tooltip, } from 'chart.js' +import { noop } from '@Common/Helper' +import { useTheme } from '@Shared/Providers' + import { LEGENDS_LABEL_CONFIG } from './constants' import { ChartProps } from './types' import { getChartJSType, getDefaultOptions, transformDataForChart } from './utils' @@ -37,22 +40,33 @@ ChartJS.register( Filler, ) +/** + * The doughnut chart overrides the default legend label configuration. + * Therefore need to set the custom legend label configuration. + */ ChartJS.overrides.doughnut.plugins.legend.labels = { ...ChartJS.overrides.doughnut.plugins.legend.labels, ...LEGENDS_LABEL_CONFIG, } -const Chart = ({ id, type, labels, datasets, className, style }: ChartProps) => { +const Chart = ({ id, type, xAxisLabels: labels, datasets }: ChartProps) => { const canvasRef = useRef(null) const chartRef = useRef(null) + /** Trigger a re-render when the theme changes to reflect the latest changes */ + const { appTheme } = useTheme() + useEffect(() => { - const ctx = canvasRef.current.getContext('2d') + const ctx = canvasRef.current?.getContext('2d') + + if (!ctx) { + return noop + } // Get Chart.js type and transform data const chartJSType = getChartJSType(type) - const transformedData = transformDataForChart(labels, datasets, type) - const defaultOptions = getDefaultOptions(type) + const transformedData = { labels, datasets: transformDataForChart(type, datasets, appTheme) } + const defaultOptions = getDefaultOptions(type, appTheme) // Create new chart chartRef.current = new ChartJS(ctx, { @@ -62,13 +76,13 @@ const Chart = ({ id, type, labels, datasets, className, style }: ChartProps) => }) return () => { - chartRef.current?.destroy() + chartRef.current.destroy() } - }, [type, datasets, labels]) + }, [type, datasets, labels, appTheme]) return ( -
- +
+
) } diff --git a/src/Shared/Components/Charts/constants.ts b/src/Shared/Components/Charts/constants.ts index ec22128fe..7d6cc2cb1 100644 --- a/src/Shared/Components/Charts/constants.ts +++ b/src/Shared/Components/Charts/constants.ts @@ -1,3 +1,7 @@ +import { AppThemeType } from '@Shared/Providers' + +import { ChartColorKey } from './types' + export const LEGENDS_LABEL_CONFIG = { usePointStyle: true, pointStyle: 'rectRounded', @@ -9,3 +13,133 @@ export const LEGENDS_LABEL_CONFIG = { weight: 400, }, } as const + +export const CHART_COLORS: Record> = { + [AppThemeType.light]: { + // Sky Blue + SkyBlue100: '#e0f2fe', + SkyBlue200: '#bae6fd', + SkyBlue300: '#7dd3fc', + SkyBlue400: '#38bdf8', + SkyBlue500: '#0ea5e9', + SkyBlue600: '#0284c7', + SkyBlue700: '#0369a1', + SkyBlue800: '#075985', + + // Aqua Teal + AquaTeal100: '#ccfbf1', + AquaTeal200: '#99f6e4', + AquaTeal300: '#5eead4', + AquaTeal400: '#2dd4bf', + AquaTeal500: '#14b8a6', + AquaTeal600: '#0d9488', + AquaTeal700: '#0f766e', + AquaTeal800: '#115e59', + + // Lavender Purple + LavenderPurple100: '#f3e8ff', + LavenderPurple200: '#e9d5ff', + LavenderPurple300: '#d8b4fe', + LavenderPurple400: '#c084fc', + LavenderPurple500: '#a855f7', + LavenderPurple600: '#9333ea', + LavenderPurple700: '#7c3aed', + LavenderPurple800: '#6b21c8', + + // Slate + Slate100: '#f1f5f9', + Slate200: '#e2e8f0', + Slate300: '#cbd5e1', + Slate400: '#94a3b8', + Slate500: '#64748b', + Slate600: '#475569', + Slate700: '#334155', + Slate800: '#1e293b', + + // Deep Plum + DeepPlum100: '#fdf2f8', + DeepPlum200: '#fce7f3', + DeepPlum300: '#f9a8d4', + DeepPlum400: '#f472b6', + DeepPlum500: '#ec4899', + DeepPlum600: '#db2777', + DeepPlum700: '#be185d', + DeepPlum800: '#9d174d', + + // Magenta (with M prefix) + Magenta100: '#fdf2f8', + Magenta200: '#fce7f3', + Magenta300: '#f9a8d4', + Magenta400: '#f472b6', + Magenta500: '#ec4899', + Magenta600: '#db2777', + Magenta700: '#be185d', + Magenta800: '#9d174d', + }, + [AppThemeType.dark]: { + // Sky Blue + SkyBlue100: '#e0f2fe', + SkyBlue200: '#bae6fd', + SkyBlue300: '#7dd3fc', + SkyBlue400: '#38bdf8', + SkyBlue500: '#0ea5e9', + SkyBlue600: '#0284c7', + SkyBlue700: '#0369a1', + SkyBlue800: '#075985', + + // Aqua Teal + AquaTeal100: '#ccfbf1', + AquaTeal200: '#99f6e4', + AquaTeal300: '#5eead4', + AquaTeal400: '#2dd4bf', + AquaTeal500: '#14b8a6', + AquaTeal600: '#0d9488', + AquaTeal700: '#0f766e', + AquaTeal800: '#115e59', + + // Lavender Purple + LavenderPurple100: '#f3e8ff', + LavenderPurple200: '#e9d5ff', + LavenderPurple300: '#d8b4fe', + LavenderPurple400: '#c084fc', + LavenderPurple500: '#a855f7', + LavenderPurple600: '#9333ea', + LavenderPurple700: '#7c3aed', + LavenderPurple800: '#6b21c8', + + // Slate + Slate100: '#f1f5f9', + Slate200: '#e2e8f0', + Slate300: '#cbd5e1', + Slate400: '#94a3b8', + Slate500: '#64748b', + Slate600: '#475569', + Slate700: '#334155', + Slate800: '#1e293b', + + // Deep Plum + DeepPlum100: '#fdf2f8', + DeepPlum200: '#fce7f3', + DeepPlum300: '#f9a8d4', + DeepPlum400: '#f472b6', + DeepPlum500: '#ec4899', + DeepPlum600: '#db2777', + DeepPlum700: '#be185d', + DeepPlum800: '#9d174d', + + // Magenta (with M prefix) + Magenta100: '#fdf2f8', + Magenta200: '#fce7f3', + Magenta300: '#f9a8d4', + Magenta400: '#f472b6', + Magenta500: '#ec4899', + Magenta600: '#db2777', + Magenta700: '#be185d', + Magenta800: '#9d174d', + }, +} as const + +export const CHART_GRID_COLORS: Record = { + [AppThemeType.light]: '#f1f5f9', + [AppThemeType.dark]: '#1e293b', +} diff --git a/src/Shared/Components/Charts/index.ts b/src/Shared/Components/Charts/index.ts index 971e5ae16..ad481d338 100644 --- a/src/Shared/Components/Charts/index.ts +++ b/src/Shared/Components/Charts/index.ts @@ -1,2 +1,2 @@ export { default as Chart } from './Chart.component' -export type { ChartProps, SimpleDataset } from './types' +export type { ChartColorKey, ChartProps, ChartType, SimpleDataset, SimpleDatasetForPie } from './types' diff --git a/src/Shared/Components/Charts/types.ts b/src/Shared/Components/Charts/types.ts index 1f1acf515..138050651 100644 --- a/src/Shared/Components/Charts/types.ts +++ b/src/Shared/Components/Charts/types.ts @@ -1,15 +1,50 @@ -export type ChartType = 'area' | 'pie' | 'stackedBar' | 'stackedBarHorizontal' +export type ChartType = 'area' | 'pie' | 'stackedBar' | 'stackedBarHorizontal' | 'line' -export interface SimpleDataset { - label: string - data: number[] +type ColorTokensType = 'DeepPlum' | 'Magenta' | 'Slate' | 'LavenderPurple' | 'SkyBlue' | 'AquaTeal' + +type VariantsType = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 + +export type ChartColorKey = `${ColorTokensType}${VariantsType}` + +export type ChartTypeWithoutPie = Exclude + +interface BaseSimpleDataset { + datasetName: string + yAxisValues: number[] } -export interface ChartProps { - id: string - type: ChartType - labels: string[] - datasets: SimpleDataset[] - className?: string - style?: React.CSSProperties +export interface SimpleDataset extends BaseSimpleDataset { + backgroundColor: ChartColorKey } + +export interface SimpleDatasetForPie extends BaseSimpleDataset { + backgroundColor: Array +} + +export interface SimpleDatasetForLine extends BaseSimpleDataset { + borderColor: ChartColorKey +} + +export type ChartProps = { + id: string + /** + * The x-axis labels. Needs to be memoized + */ + xAxisLabels: string[] +} & ( + | { + type: 'pie' + /** + * Needs to be memoized + */ + datasets: SimpleDatasetForPie + } + | { + type: 'line' + datasets: SimpleDatasetForLine[] + } + | { + type: Exclude + datasets: SimpleDataset[] + } +) diff --git a/src/Shared/Components/Charts/utils.ts b/src/Shared/Components/Charts/utils.ts index 8f3344ebf..0eb23334b 100644 --- a/src/Shared/Components/Charts/utils.ts +++ b/src/Shared/Components/Charts/utils.ts @@ -1,25 +1,15 @@ -import { ChartData, ChartDataset, ChartOptions, ChartType as ChartJSChartType } from 'chart.js' +import { ChartDataset, ChartOptions, ChartType as ChartJSChartType } from 'chart.js' -import { LEGENDS_LABEL_CONFIG } from './constants' -import { ChartType, SimpleDataset } from './types' +import { AppThemeType } from '@Shared/Providers' -const getCSSVariableValue = (variableName: string) => { - const value = getComputedStyle(document.querySelector('#devtron-base-main-identifier')).getPropertyValue( - variableName, - ) - - if (!value) { - // eslint-disable-next-line no-console - console.error(`CSS variable "${variableName}" not found`) - } - - return value ?? 'transparent' -} +import { CHART_COLORS, CHART_GRID_COLORS, LEGENDS_LABEL_CONFIG } from './constants' +import { ChartColorKey, ChartProps, ChartType, SimpleDataset, SimpleDatasetForLine, SimpleDatasetForPie } from './types' // Map our chart types to Chart.js types export const getChartJSType = (type: ChartType): ChartJSChartType => { switch (type) { case 'area': + case 'line': return 'line' case 'pie': return 'doughnut' @@ -32,11 +22,10 @@ export const getChartJSType = (type: ChartType): ChartJSChartType => { } // Get default options based on chart type -export const getDefaultOptions = (type: ChartType): ChartOptions => { +export const getDefaultOptions = (type: ChartType, appTheme: AppThemeType): ChartOptions => { const baseOptions: ChartOptions = { responsive: true, maintainAspectRatio: false, - devicePixelRatio: 3, plugins: { legend: { position: 'bottom' as const, @@ -48,7 +37,7 @@ export const getDefaultOptions = (type: ChartType): ChartOptions => { }, elements: { line: { - fill: true, + fill: type === 'area', tension: 0.4, }, bar: { @@ -58,17 +47,20 @@ export const getDefaultOptions = (type: ChartType): ChartOptions => { borderRadius: 4, }, arc: { - spacing: 2, + spacing: 12, + borderRadius: 4, + borderWidth: 0, }, }, } const gridConfig = { - color: getCSSVariableValue('--N50'), + color: CHART_GRID_COLORS[appTheme], } switch (type) { case 'area': + case 'line': return { ...baseOptions, plugins: { @@ -84,7 +76,7 @@ export const getDefaultOptions = (type: ChartType): ChartOptions => { }, scales: { y: { - stacked: true, + stacked: type === 'area', beginAtZero: true, grid: gridConfig, }, @@ -107,7 +99,7 @@ export const getDefaultOptions = (type: ChartType): ChartOptions => { grid: gridConfig, }, }, - } + } as ChartOptions<'bar'> case 'stackedBarHorizontal': return { ...baseOptions, @@ -123,7 +115,7 @@ export const getDefaultOptions = (type: ChartType): ChartOptions => { grid: gridConfig, }, }, - } + } as ChartOptions<'bar'> case 'pie': return { ...baseOptions, @@ -134,75 +126,115 @@ export const getDefaultOptions = (type: ChartType): ChartOptions => { align: 'center', }, }, - } + cutout: '60%', + radius: '80%', + } as ChartOptions<'doughnut'> default: return baseOptions } } -// Generates a palette of pastel HSL colors -const generateColors = (count: number): string[] => { - const colors: string[] = [] - for (let i = 0; i < count; i++) { - const hue = (i * 360) / count - const saturation = 50 // Pastel: 40-60% - const lightness = 75 // Pastel: 80-90% - colors.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`) - } - return colors -} +// Get color value from chart color key +const getColorValue = (colorKey: ChartColorKey, appTheme: AppThemeType): string => CHART_COLORS[appTheme][colorKey] -// Generates a slightly darker shade for a given HSL color string -const generateCorrespondingBorderColor = (hsl: string): string => { - // Parse hsl string: hsl(hue, saturation%, lightness%) - const match = hsl.match(/hsl\((\d+),\s*([\d.]+)%,\s*([\d.]+)%\)/) - if (!match) throw new Error('Invalid HSL color format') - const hue = Number(match[1]) - const saturation = Number(match[2]) - let lightness = Number(match[3]) - lightness = Math.max(0, lightness - 15) // Clamp to 0 - return `hsl(${hue}, ${saturation}%, ${lightness}%)` -} +// Generates a slightly darker shade for a given color key +const generateCorrespondingBorderColor = (colorKey: ChartColorKey): string => { + // Extract the base color name and shade number + const colorName = colorKey.replace(/\d+$/, '') + const shadeMatch = colorKey.match(/\d+$/) + const currentShade = shadeMatch ? parseInt(shadeMatch[0], 10) : 500 -// Transform simple data to Chart.js format with consistent styling -export const transformDataForChart = (labels: string[], datasets: SimpleDataset[], type: ChartType): ChartData => { - const colors = generateColors(type === 'pie' ? datasets[0].data.length : datasets.length) + // Try to get a darker shade (higher number) + const darkerShade = Math.min(currentShade + 200, 800) + const borderColorKey = `${colorName}${darkerShade}` as ChartColorKey - const transformedDatasets = datasets.map((dataset, index) => { - const colorIndex = index % colors.length - const baseDataset = { - label: dataset.label, - data: dataset.data, - backgroundColor: colors[colorIndex], + // If the darker shade exists, use it; otherwise, use the current color + return CHART_COLORS[borderColorKey] || CHART_COLORS[colorKey] +} + +const getBackgroundAndBorderColor = ( + type: ChartType, + dataset: SimpleDataset | SimpleDatasetForPie | SimpleDatasetForLine, + appTheme: AppThemeType, +) => { + if (type === 'pie') { + return { + backgroundColor: (dataset as SimpleDatasetForPie).backgroundColor.map((colorKey) => + getColorValue(colorKey, appTheme), + ), + borderColor: 'transparent', } + } - switch (type) { - case 'area': - return { - ...baseDataset, - fill: true, - pointRadius: 0, - pointHoverRadius: 10, - pointHitRadius: 20, - pointStyle: 'rectRounded', - pointBorderWidth: 0, - borderWidth: 2, - borderColor: generateCorrespondingBorderColor(colors[colorIndex]), - } as ChartDataset<'line'> - case 'pie': - return { - ...baseDataset, - backgroundColor: colors.slice(0, dataset.data.length), - } - case 'stackedBar': - case 'stackedBarHorizontal': - default: - return baseDataset + if (type === 'line') { + const borderColor = getColorValue((dataset as SimpleDatasetForLine).borderColor, appTheme) + + return { + backgroundColor: borderColor, + borderColor, } - }) + } return { - labels, - datasets: transformedDatasets, + backgroundColor: getColorValue((dataset as SimpleDataset).backgroundColor, appTheme), + borderColor: + type === 'area' + ? generateCorrespondingBorderColor((dataset as SimpleDataset).backgroundColor) + : 'transparent', } } + +const transformDataset = ( + type: ChartType, + dataset: SimpleDataset | SimpleDatasetForPie | SimpleDatasetForLine, + appTheme: AppThemeType, +) => { + const { backgroundColor, borderColor } = getBackgroundAndBorderColor(type, dataset, appTheme) + + const baseDataset = { + label: dataset.datasetName, + data: dataset.yAxisValues, + backgroundColor, + borderColor, + } + + switch (type) { + case 'line': + case 'area': + return { + ...baseDataset, + fill: type === 'area', + pointRadius: 0, + pointHoverRadius: 10, + pointHitRadius: 20, + pointStyle: 'rectRounded', + pointBorderWidth: 0, + borderWidth: 2, + } as ChartDataset<'line'> + case 'pie': + case 'stackedBar': + case 'stackedBarHorizontal': + default: + return baseDataset + } +} + +export const transformDataForChart = (type: ChartType, datasets: ChartProps['datasets'], appTheme: AppThemeType) => { + if (!datasets) { + // eslint-disable-next-line no-console + console.error('No datasets provided for chart transformation') + return [] + } + + if (type === 'pie') { + return [transformDataset(type, datasets as SimpleDatasetForPie, appTheme)] + } + + if (!Array.isArray(datasets)) { + // eslint-disable-next-line no-console + console.error('Invalid datasets format. Expected an array.') + return [] + } + + return datasets.map((dataset: SimpleDatasetForLine | SimpleDataset) => transformDataset(type, dataset, appTheme)) +} From 98f1ebebb53c9c1b7e123d65b13cf7819625b886 Mon Sep 17 00:00:00 2001 From: Amrit Kashyap Borah Date: Wed, 20 Aug 2025 15:51:38 +0530 Subject: [PATCH 5/7] feat: add gradient to area chart --- src/Shared/Components/Charts/constants.ts | 7 ++++- src/Shared/Components/Charts/utils.ts | 36 +++++++++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/Shared/Components/Charts/constants.ts b/src/Shared/Components/Charts/constants.ts index 7d6cc2cb1..d0944efd2 100644 --- a/src/Shared/Components/Charts/constants.ts +++ b/src/Shared/Components/Charts/constants.ts @@ -139,7 +139,12 @@ export const CHART_COLORS: Record> = }, } as const -export const CHART_GRID_COLORS: Record = { +export const CHART_GRID_LINES_COLORS: Record = { [AppThemeType.light]: '#f1f5f9', [AppThemeType.dark]: '#1e293b', } + +export const CHART_CANVAS_BACKGROUND_COLORS: Record = { + [AppThemeType.light]: '#ffffff', + [AppThemeType.dark]: '#1e293b', +} diff --git a/src/Shared/Components/Charts/utils.ts b/src/Shared/Components/Charts/utils.ts index 0eb23334b..04905efae 100644 --- a/src/Shared/Components/Charts/utils.ts +++ b/src/Shared/Components/Charts/utils.ts @@ -2,7 +2,12 @@ import { ChartDataset, ChartOptions, ChartType as ChartJSChartType } from 'chart import { AppThemeType } from '@Shared/Providers' -import { CHART_COLORS, CHART_GRID_COLORS, LEGENDS_LABEL_CONFIG } from './constants' +import { + CHART_CANVAS_BACKGROUND_COLORS, + CHART_COLORS, + CHART_GRID_LINES_COLORS, + LEGENDS_LABEL_CONFIG, +} from './constants' import { ChartColorKey, ChartProps, ChartType, SimpleDataset, SimpleDatasetForLine, SimpleDatasetForPie } from './types' // Map our chart types to Chart.js types @@ -55,7 +60,7 @@ export const getDefaultOptions = (type: ChartType, appTheme: AppThemeType): Char } const gridConfig = { - color: CHART_GRID_COLORS[appTheme], + color: CHART_GRID_LINES_COLORS[appTheme], } switch (type) { @@ -175,12 +180,31 @@ const getBackgroundAndBorderColor = ( } } + if (type === 'area') { + const bgColor = getColorValue((dataset as SimpleDataset).backgroundColor, appTheme) + + return { + backgroundColor(context) { + const { ctx, chartArea } = context.chart + + if (!chartArea) { + // happens on initial render + return null + } + + const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom) + gradient.addColorStop(0, bgColor) + gradient.addColorStop(1, CHART_CANVAS_BACKGROUND_COLORS[appTheme]) + + return gradient + }, + borderColor: generateCorrespondingBorderColor((dataset as SimpleDataset).backgroundColor), + } as Pick, 'backgroundColor' | 'borderColor'> + } + return { backgroundColor: getColorValue((dataset as SimpleDataset).backgroundColor, appTheme), - borderColor: - type === 'area' - ? generateCorrespondingBorderColor((dataset as SimpleDataset).backgroundColor) - : 'transparent', + borderColor: 'transparent', } } From f9add16ccb4c008b1bd12e6eb67a54dc9e90989e Mon Sep 17 00:00:00 2001 From: Amrit Kashyap Borah Date: Wed, 20 Aug 2025 16:04:24 +0530 Subject: [PATCH 6/7] chore: add documentation comment to chart component --- .../Components/Charts/Chart.component.tsx | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/src/Shared/Components/Charts/Chart.component.tsx b/src/Shared/Components/Charts/Chart.component.tsx index 47bea6771..925163c53 100644 --- a/src/Shared/Components/Charts/Chart.component.tsx +++ b/src/Shared/Components/Charts/Chart.component.tsx @@ -49,6 +49,105 @@ ChartJS.overrides.doughnut.plugins.legend.labels = { ...LEGENDS_LABEL_CONFIG, } +/** + * A versatile Chart component that renders different types of charts using Chart.js. + * Supports area charts, pie charts, stacked bar charts (vertical/horizontal), and line charts. + * + * The component automatically adapts to theme changes and provides consistent styling + * across all chart types. Colors are provided by the user through the CHART_COLORS constant + * or custom color tokens. + * + * @example + * ```tsx + * [Area Chart Example] + * + * + * [Pie Chart Example] + * + * + * [Line Chart Example (non-stacked, non-filled)] + * + * + * [Stacked Bar Chart Example] + * + * ``` + * + * @param id - Unique identifier for the chart canvas element + * @param type - Chart type: 'area', 'pie', 'stackedBar', 'stackedBarHorizontal', or 'line' + * @param xAxisLabels - Array of labels for the x-axis (or categories for pie charts) + * @param datasets - Chart data: array of datasets for most charts, single dataset object for pie charts + * + * @performance + * **Memoization Recommendations:** + * - `xAxisLabels`: Should be memoized with useMemo() if derived from complex calculations + * - `datasets`: Should be memoized with useMemo() as it contains arrays and objects that cause re-renders + * - Avoid passing inline objects or arrays directly to these props + * + * @example + * ```tsx + * [Good: Memoized props prevent unnecessary re-renders] + * const labels = useMemo(() => quarters.map(q => `Q${q}`), [quarters]) + * const chartDatasets = useMemo(() => [ + * { + * datasetName: 'Revenue', + * yAxisValues: revenueData, + * backgroundColor: 'LavenderPurple300' + * } + * ], [revenueData]) + * + * return + * ``` + * + * @notes + * - Chart automatically re-renders when theme changes (light/dark mode) + * - Line charts are rendered as non-stacked and non-filled by default + * - Pie charts expect a single dataset object instead of an array + * - Colors should reference CHART_COLORS tokens for consistency + * - Component destroys and recreates Chart.js instance on prop changes for optimal performance + */ const Chart = ({ id, type, xAxisLabels: labels, datasets }: ChartProps) => { const canvasRef = useRef(null) const chartRef = useRef(null) From bfa7e765f41cbb2efc6defcb53a642ccf68039b8 Mon Sep 17 00:00:00 2001 From: Amrit Kashyap Borah Date: Wed, 20 Aug 2025 17:28:18 +0530 Subject: [PATCH 7/7] refactor: improve types --- .../Components/Charts/Chart.component.tsx | 8 ++- src/Shared/Components/Charts/types.ts | 38 ++++++++++--- src/Shared/Components/Charts/utils.ts | 53 ++++++++++--------- 3 files changed, 67 insertions(+), 32 deletions(-) diff --git a/src/Shared/Components/Charts/Chart.component.tsx b/src/Shared/Components/Charts/Chart.component.tsx index 925163c53..a17e7e7c2 100644 --- a/src/Shared/Components/Charts/Chart.component.tsx +++ b/src/Shared/Components/Charts/Chart.component.tsx @@ -148,7 +148,11 @@ ChartJS.overrides.doughnut.plugins.legend.labels = { * - Colors should reference CHART_COLORS tokens for consistency * - Component destroys and recreates Chart.js instance on prop changes for optimal performance */ -const Chart = ({ id, type, xAxisLabels: labels, datasets }: ChartProps) => { +const Chart = (props: ChartProps) => { + /** Using this technique for typing in transformDataForChart */ + const { id, xAxisLabels: labels, ...typeAndDatasets } = props + const { type, datasets } = typeAndDatasets + const canvasRef = useRef(null) const chartRef = useRef(null) @@ -164,7 +168,7 @@ const Chart = ({ id, type, xAxisLabels: labels, datasets }: ChartProps) => { // Get Chart.js type and transform data const chartJSType = getChartJSType(type) - const transformedData = { labels, datasets: transformDataForChart(type, datasets, appTheme) } + const transformedData = { labels, datasets: transformDataForChart({ ...typeAndDatasets, appTheme }) } const defaultOptions = getDefaultOptions(type, appTheme) // Create new chart diff --git a/src/Shared/Components/Charts/types.ts b/src/Shared/Components/Charts/types.ts index 138050651..aea0c5ece 100644 --- a/src/Shared/Components/Charts/types.ts +++ b/src/Shared/Components/Charts/types.ts @@ -1,3 +1,5 @@ +import { AppThemeType } from '@Shared/Providers' + export type ChartType = 'area' | 'pie' | 'stackedBar' | 'stackedBarHorizontal' | 'line' type ColorTokensType = 'DeepPlum' | 'Magenta' | 'Slate' | 'LavenderPurple' | 'SkyBlue' | 'AquaTeal' @@ -25,26 +27,50 @@ export interface SimpleDatasetForLine extends BaseSimpleDataset { borderColor: ChartColorKey } +type TypeAndDatasetsType = + | { + type: 'pie' + /** + * Needs to be memoized + */ + datasets: SimpleDatasetForPie + } + | { + type: 'line' + datasets: SimpleDatasetForLine[] + } + | { + type: Exclude + datasets: SimpleDataset[] + } + export type ChartProps = { id: string /** * The x-axis labels. Needs to be memoized */ xAxisLabels: string[] +} & TypeAndDatasetsType + +export type TransformDatasetProps = { + appTheme: AppThemeType } & ( | { type: 'pie' - /** - * Needs to be memoized - */ - datasets: SimpleDatasetForPie + dataset: SimpleDatasetForPie } | { type: 'line' - datasets: SimpleDatasetForLine[] + dataset: SimpleDatasetForLine } | { type: Exclude - datasets: SimpleDataset[] + dataset: SimpleDataset } ) + +export type GetBackgroundAndBorderColorProps = TransformDatasetProps + +export type TransformDataForChartProps = { + appTheme: AppThemeType +} & TypeAndDatasetsType diff --git a/src/Shared/Components/Charts/utils.ts b/src/Shared/Components/Charts/utils.ts index 04905efae..84c464a0e 100644 --- a/src/Shared/Components/Charts/utils.ts +++ b/src/Shared/Components/Charts/utils.ts @@ -8,7 +8,14 @@ import { CHART_GRID_LINES_COLORS, LEGENDS_LABEL_CONFIG, } from './constants' -import { ChartColorKey, ChartProps, ChartType, SimpleDataset, SimpleDatasetForLine, SimpleDatasetForPie } from './types' +import { + ChartColorKey, + ChartType, + GetBackgroundAndBorderColorProps, + SimpleDataset, + TransformDataForChartProps, + TransformDatasetProps, +} from './types' // Map our chart types to Chart.js types export const getChartJSType = (type: ChartType): ChartJSChartType => { @@ -157,22 +164,16 @@ const generateCorrespondingBorderColor = (colorKey: ChartColorKey): string => { return CHART_COLORS[borderColorKey] || CHART_COLORS[colorKey] } -const getBackgroundAndBorderColor = ( - type: ChartType, - dataset: SimpleDataset | SimpleDatasetForPie | SimpleDatasetForLine, - appTheme: AppThemeType, -) => { +const getBackgroundAndBorderColor = ({ type, dataset, appTheme }: GetBackgroundAndBorderColorProps) => { if (type === 'pie') { return { - backgroundColor: (dataset as SimpleDatasetForPie).backgroundColor.map((colorKey) => - getColorValue(colorKey, appTheme), - ), + backgroundColor: dataset.backgroundColor.map((colorKey) => getColorValue(colorKey, appTheme)), borderColor: 'transparent', } } if (type === 'line') { - const borderColor = getColorValue((dataset as SimpleDatasetForLine).borderColor, appTheme) + const borderColor = getColorValue(dataset.borderColor, appTheme) return { backgroundColor: borderColor, @@ -181,7 +182,7 @@ const getBackgroundAndBorderColor = ( } if (type === 'area') { - const bgColor = getColorValue((dataset as SimpleDataset).backgroundColor, appTheme) + const bgColor = getColorValue(dataset.backgroundColor, appTheme) return { backgroundColor(context) { @@ -208,12 +209,10 @@ const getBackgroundAndBorderColor = ( } } -const transformDataset = ( - type: ChartType, - dataset: SimpleDataset | SimpleDatasetForPie | SimpleDatasetForLine, - appTheme: AppThemeType, -) => { - const { backgroundColor, borderColor } = getBackgroundAndBorderColor(type, dataset, appTheme) +const transformDataset = (props: TransformDatasetProps) => { + const { dataset, type } = props + + const { backgroundColor, borderColor } = getBackgroundAndBorderColor(props) const baseDataset = { label: dataset.datasetName, @@ -243,22 +242,28 @@ const transformDataset = ( } } -export const transformDataForChart = (type: ChartType, datasets: ChartProps['datasets'], appTheme: AppThemeType) => { +export const transformDataForChart = (props: TransformDataForChartProps) => { + const { type, datasets, appTheme } = props + if (!datasets) { // eslint-disable-next-line no-console console.error('No datasets provided for chart transformation') return [] } - if (type === 'pie') { - return [transformDataset(type, datasets as SimpleDatasetForPie, appTheme)] - } - - if (!Array.isArray(datasets)) { + if (type !== 'pie' && !Array.isArray(datasets)) { // eslint-disable-next-line no-console console.error('Invalid datasets format. Expected an array.') return [] } - return datasets.map((dataset: SimpleDatasetForLine | SimpleDataset) => transformDataset(type, dataset, appTheme)) + switch (type) { + case 'pie': + return [transformDataset({ type, dataset: datasets, appTheme })] + /** Not not clubbing it with the default case for better typing */ + case 'line': + return datasets.map((dataset) => transformDataset({ type, dataset, appTheme })) + default: + return datasets.map((dataset) => transformDataset({ type, dataset, appTheme })) + } }