diff --git a/package-lock.json b/package-lock.json
index ebf2a5563..7564c9720 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 180a6b912..1511c02cf 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..a17e7e7c2
--- /dev/null
+++ b/src/Shared/Components/Charts/Chart.component.tsx
@@ -0,0 +1,193 @@
+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 { 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'
+
+// Register Chart.js components
+ChartJS.register(
+ CategoryScale,
+ LinearScale,
+ BarController,
+ BarElement,
+ LineController,
+ LineElement,
+ PointElement,
+ DoughnutController,
+ ArcElement,
+ Title,
+ Tooltip,
+ Legend,
+ 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,
+}
+
+/**
+ * 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 = (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)
+
+ /** Trigger a re-render when the theme changes to reflect the latest changes */
+ const { appTheme } = useTheme()
+
+ useEffect(() => {
+ const ctx = canvasRef.current?.getContext('2d')
+
+ if (!ctx) {
+ return noop
+ }
+
+ // Get Chart.js type and transform data
+ const chartJSType = getChartJSType(type)
+ const transformedData = { labels, datasets: transformDataForChart({ ...typeAndDatasets, appTheme }) }
+ const defaultOptions = getDefaultOptions(type, appTheme)
+
+ // Create new chart
+ chartRef.current = new ChartJS(ctx, {
+ type: chartJSType,
+ data: transformedData,
+ options: defaultOptions,
+ })
+
+ return () => {
+ chartRef.current.destroy()
+ }
+ }, [type, datasets, labels, appTheme])
+
+ return (
+
+
+
+ )
+}
+
+export default Chart
diff --git a/src/Shared/Components/Charts/constants.ts b/src/Shared/Components/Charts/constants.ts
new file mode 100644
index 000000000..d0944efd2
--- /dev/null
+++ b/src/Shared/Components/Charts/constants.ts
@@ -0,0 +1,150 @@
+import { AppThemeType } from '@Shared/Providers'
+
+import { ChartColorKey } from './types'
+
+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
+
+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_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/index.ts b/src/Shared/Components/Charts/index.ts
new file mode 100644
index 000000000..ad481d338
--- /dev/null
+++ b/src/Shared/Components/Charts/index.ts
@@ -0,0 +1,2 @@
+export { default as Chart } from './Chart.component'
+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
new file mode 100644
index 000000000..aea0c5ece
--- /dev/null
+++ b/src/Shared/Components/Charts/types.ts
@@ -0,0 +1,76 @@
+import { AppThemeType } from '@Shared/Providers'
+
+export type ChartType = 'area' | 'pie' | 'stackedBar' | 'stackedBarHorizontal' | 'line'
+
+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 SimpleDataset extends BaseSimpleDataset {
+ backgroundColor: ChartColorKey
+}
+
+export interface SimpleDatasetForPie extends BaseSimpleDataset {
+ backgroundColor: Array
+}
+
+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'
+ dataset: SimpleDatasetForPie
+ }
+ | {
+ type: 'line'
+ dataset: SimpleDatasetForLine
+ }
+ | {
+ type: Exclude
+ 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
new file mode 100644
index 000000000..84c464a0e
--- /dev/null
+++ b/src/Shared/Components/Charts/utils.ts
@@ -0,0 +1,269 @@
+import { ChartDataset, ChartOptions, ChartType as ChartJSChartType } from 'chart.js'
+
+import { AppThemeType } from '@Shared/Providers'
+
+import {
+ CHART_CANVAS_BACKGROUND_COLORS,
+ CHART_COLORS,
+ CHART_GRID_LINES_COLORS,
+ LEGENDS_LABEL_CONFIG,
+} from './constants'
+import {
+ ChartColorKey,
+ ChartType,
+ GetBackgroundAndBorderColorProps,
+ SimpleDataset,
+ TransformDataForChartProps,
+ TransformDatasetProps,
+} 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'
+ case 'stackedBar':
+ case 'stackedBarHorizontal':
+ return 'bar'
+ default:
+ return type as ChartJSChartType
+ }
+}
+
+// Get default options based on chart type
+export const getDefaultOptions = (type: ChartType, appTheme: AppThemeType): ChartOptions => {
+ const baseOptions: ChartOptions = {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ position: 'bottom' as const,
+ labels: LEGENDS_LABEL_CONFIG,
+ },
+ title: {
+ display: false,
+ },
+ },
+ elements: {
+ line: {
+ fill: type === 'area',
+ tension: 0.4,
+ },
+ bar: {
+ borderSkipped: 'start' as const,
+ borderWidth: 2,
+ borderColor: 'transparent',
+ borderRadius: 4,
+ },
+ arc: {
+ spacing: 12,
+ borderRadius: 4,
+ borderWidth: 0,
+ },
+ },
+ }
+
+ const gridConfig = {
+ color: CHART_GRID_LINES_COLORS[appTheme],
+ }
+
+ switch (type) {
+ case 'area':
+ case 'line':
+ return {
+ ...baseOptions,
+ plugins: {
+ ...baseOptions.plugins,
+ tooltip: {
+ mode: 'index',
+ },
+ },
+ interaction: {
+ mode: 'nearest',
+ axis: 'x',
+ intersect: false,
+ },
+ scales: {
+ y: {
+ stacked: type === 'area',
+ beginAtZero: true,
+ grid: gridConfig,
+ },
+ x: {
+ grid: gridConfig,
+ },
+ },
+ } as ChartOptions<'line'>
+ case 'stackedBar':
+ return {
+ ...baseOptions,
+ scales: {
+ x: {
+ stacked: true,
+ grid: gridConfig,
+ },
+ y: {
+ stacked: true,
+ beginAtZero: true,
+ grid: gridConfig,
+ },
+ },
+ } as ChartOptions<'bar'>
+ case 'stackedBarHorizontal':
+ return {
+ ...baseOptions,
+ indexAxis: 'y' as const,
+ scales: {
+ x: {
+ stacked: true,
+ beginAtZero: true,
+ grid: gridConfig,
+ },
+ y: {
+ stacked: true,
+ grid: gridConfig,
+ },
+ },
+ } as ChartOptions<'bar'>
+ case 'pie':
+ return {
+ ...baseOptions,
+ plugins: {
+ ...baseOptions.plugins,
+ legend: {
+ position: 'right',
+ align: 'center',
+ },
+ },
+ cutout: '60%',
+ radius: '80%',
+ } as ChartOptions<'doughnut'>
+ default:
+ return baseOptions
+ }
+}
+
+// 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 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
+
+ // Try to get a darker shade (higher number)
+ const darkerShade = Math.min(currentShade + 200, 800)
+ const borderColorKey = `${colorName}${darkerShade}` as ChartColorKey
+
+ // If the darker shade exists, use it; otherwise, use the current color
+ return CHART_COLORS[borderColorKey] || CHART_COLORS[colorKey]
+}
+
+const getBackgroundAndBorderColor = ({ type, dataset, appTheme }: GetBackgroundAndBorderColorProps) => {
+ if (type === 'pie') {
+ return {
+ backgroundColor: dataset.backgroundColor.map((colorKey) => getColorValue(colorKey, appTheme)),
+ borderColor: 'transparent',
+ }
+ }
+
+ if (type === 'line') {
+ const borderColor = getColorValue(dataset.borderColor, appTheme)
+
+ return {
+ backgroundColor: borderColor,
+ borderColor,
+ }
+ }
+
+ if (type === 'area') {
+ const bgColor = getColorValue(dataset.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: 'transparent',
+ }
+}
+
+const transformDataset = (props: TransformDatasetProps) => {
+ const { dataset, type } = props
+
+ const { backgroundColor, borderColor } = getBackgroundAndBorderColor(props)
+
+ 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 = (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' && !Array.isArray(datasets)) {
+ // eslint-disable-next-line no-console
+ console.error('Invalid datasets format. Expected an array.')
+ return []
+ }
+
+ 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 }))
+ }
+}
diff --git a/src/Shared/Components/index.ts b/src/Shared/Components/index.ts
index 47f891db8..e9c200a5d 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'