Skip to content

feat: Chart component using chart.js #858

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Aug 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
193 changes: 193 additions & 0 deletions src/Shared/Components/Charts/Chart.component.tsx
Original file line number Diff line number Diff line change
@@ -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]
* <Chart
* id="quarterly-growth"
* type="area"
* xAxisLabels={['Q1 2023', 'Q2 2023', 'Q3 2023', 'Q4 2023']}
* datasets={[{
* datasetName: 'Revenue Growth (%)',
* yAxisValues: [15.2, 18.7, 22.3, 19.8],
* backgroundColor: 'LavenderPurple300'
* }]}
* />
*
* [Pie Chart Example]
* <Chart
* id="technology-adoption"
* type="pie"
* xAxisLabels={['React', 'Vue.js', 'Angular']}
* datasets={{
* datasetName: 'Adoption Rate (%)',
* yAxisValues: [45.2, 28.7, 35.4],
* backgroundColor: ['SkyBlue300', 'AquaTeal400', 'LavenderPurple300']
* }}
* />
*
* [Line Chart Example (non-stacked, non-filled)]
* <Chart
* id="traffic-trends"
* type="line"
* xAxisLabels={['Jan', 'Feb', 'Mar', 'Apr']}
* datasets={[{
* datasetName: 'Website Traffic',
* yAxisValues: [120, 190, 300, 500],
* borderColor: 'SkyBlue500'
* }]}
* />
*
* [Stacked Bar Chart Example]
* <Chart
* id="team-allocation"
* type="stackedBar"
* xAxisLabels={['Q1', 'Q2', 'Q3', 'Q4']}
* datasets={[
* {
* datasetName: 'Frontend',
* yAxisValues: [120, 150, 180, 200],
* backgroundColor: 'SkyBlue600'
* },
* {
* datasetName: 'Backend',
* yAxisValues: [80, 100, 120, 140],
* backgroundColor: 'AquaTeal600'
* }
* ]}
* />
* ```
*
* @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 <Chart id="revenue-chart" type="area" xAxisLabels={labels} datasets={chartDatasets} />
* ```
*
* @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<HTMLCanvasElement>(null)
const chartRef = useRef<ChartJS | null>(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 (
<div className="h-100 w-100">
<canvas id={id} ref={canvasRef} />
</div>
)
}

export default Chart
150 changes: 150 additions & 0 deletions src/Shared/Components/Charts/constants.ts
Original file line number Diff line number Diff line change
@@ -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, Record<ChartColorKey, string>> = {
[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, string> = {
[AppThemeType.light]: '#f1f5f9',
[AppThemeType.dark]: '#1e293b',
}

export const CHART_CANVAS_BACKGROUND_COLORS: Record<AppThemeType, string> = {
[AppThemeType.light]: '#ffffff',
[AppThemeType.dark]: '#1e293b',
}
2 changes: 2 additions & 0 deletions src/Shared/Components/Charts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as Chart } from './Chart.component'
export type { ChartColorKey, ChartProps, ChartType, SimpleDataset, SimpleDatasetForPie } from './types'
Loading