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'