import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, Input, Output, Optional, ViewChild, EventEmitter, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { AnyObject } from 'chart.js/dist/types/basic'; import { AngularSvgIconModule } from 'angular-svg-icon'; import { Subject, takeUntil } from 'rxjs'; // Charts import { CategoryScale, Chart, ChartEvent, LinearScale, Plugin, } from 'chart.js/auto'; import annotationPlugin from 'chartjs-plugin-annotation'; import { ActiveDataPoint, ChartType } from 'chart.js'; // Enums import { ChartColorsStringEnum, EChartEventProperties, ChartPluginIdsStringEnum, eChartTypesString, CubicInterpolationStringEnum, ChartEventTypesStringEnum, ChartFontPropertiesStringEnum, EChartAnnotation, } from './enums'; // Helpers import { ChartHelper } from './utils/helpers'; // Guards import { ChartTypeGuard } from './utils/guards'; //Models import { IChartData, ILineDataset, IChartConfiguration, IBaseDataset, IChartAnnotation, IChartDatasetHover, IChartCenterLabel, IChartLegendConfig, } from './models'; import { ActiveElement } from 'chart.js/dist/plugins/plugin.tooltip'; //Services import { ChartManagerService } from '../ca-chart-manager/services'; // Constants import { ChartConstants } from './utils/constants'; import { _DeepPartialArray, _DeepPartialObject, } from 'chart.js/dist/types/utils'; // Pipes import { ThousandSeparatorPipe, UnitPositionPipe } from '../../pipes'; @Component({ selector: 'app-ca-chart', templateUrl: './ca-chart.component.html', styleUrls: ['./ca-chart.component.scss'], imports: [ CommonModule, AngularSvgIconModule, UnitPositionPipe, ThousandSeparatorPipe, ], }) export class CaChartComponent implements AfterViewInit { @ViewChild('chartCanvas', { static: false }) chartCanvas!: ElementRef; @Input() legendData!: IChartLegendConfig; @Input() chartId!: string; @Input() set chartDatasetHover(value: IChartDatasetHover | null) { if (value) this.updateDatasetBackgroundOnHover(value); } @Input() set chartConfig(value: IChartConfiguration) { this._chartConfig = { ...value }; this.isChartDataAvailable = (!!this._chartConfig?.chartData?.datasets?.length && this._chartConfig?.chartData?.datasets?.some( (item: IBaseDataset) => item.data.some((value: number | number[]) => Array.isArray(value) ? value[0] !== null || value[1] !== null : value !== null ) )) ?? false; this.changeDetector.detectChanges(); if (this.isChartDataAvailable) this.updateChart(); } @Input() set selectedIndex(value: number) { if (!this.chart || !this.isChartDataAvailable) return; if (value !== null) this.focusDoughnutArc(value); else this.setOriginalDoughnutArcsColor(); this.chart.update(); } @Output() hoveredIndex: EventEmitter = new EventEmitter(); private destroy$ = new Subject(); private chart!: Chart; private chartContext!: CanvasRenderingContext2D; private plugins?: Plugin[]; private areAnimationsCompleated: boolean = false; private _hoveredIndex: number | null = null; private lastHoveredIndex: number = -1; public _chartConfig!: IChartConfiguration; public isChartDataAvailable: boolean = false; constructor( private changeDetector: ChangeDetectorRef, @Optional() private chartManagerService?: ChartManagerService ) {} ngAfterViewInit(): void { if (this.isChartDataAvailable) { this.initializeAnnotationPlugin(); this.createChart(); } } ngOnInit(): void { this.initializeChartManagerService(); } private initializeChartManagerService(): void { if (!this.chartManagerService) return; this.chartManagerService.hoverState$ .pipe(takeUntil(this.destroy$)) .subscribe(({ index, chartId }) => { if (chartId !== this.chartId) { this._hoveredIndex = index; if (this.chart) this.chart.draw(); } }); } private initializeAnnotationPlugin(): void { Chart.register(annotationPlugin); } public updateDatasetBackgroundOnHover( chartDatasetHover: IChartDatasetHover ): void { if (!this.chart || !this.chart.data.datasets) return; if (chartDatasetHover.isHoverd) { const gradient = ChartHelper.createGradient( this.chartContext, this.chartCanvas, chartDatasetHover.color, chartDatasetHover.color, 0.6, 0.1, true ); this.chart.data.datasets = this.chart?.data?.datasets?.map( (dataset) => { if (dataset.hidden) return dataset; if ( dataset?.label?.toUpperCase() === chartDatasetHover?.label?.toUpperCase() ) { const hoverdDataSet = { ...dataset, fill: true, backgroundColor: gradient, borderColor: chartDatasetHover.color, order: 1, }; return hoverdDataSet; } else { const borderColorWithOpacity = ChartHelper.changeOpacityOfRgbOrRgba( dataset.borderColor as string, 0.2 ); return { ...dataset, fill: false, borderColor: borderColorWithOpacity, }; } } ); } else this.chart.data.datasets = this.chart.data.datasets.map( (dataset) => { if (!dataset.hidden) { const originalColor = dataset.borderColor ? ChartHelper.changeOpacityOfRgbOrRgba( dataset.borderColor as string, 1 ) : ChartConstants.STRING_EMPTY; return { ...dataset, fill: false, borderColor: originalColor, backgroundColor: ChartConstants.STRING_EMPTY, }; } else return dataset; } ); this.chart.update(); } private createChart(): void { this.chartContext = this.chartCanvas?.nativeElement?.getContext('2d'); this.setChartOptionsProperties(); this.setChartDataProperties(); this.setChartPluginsProperties(); if (this.chart) this.chart.destroy(); this.initializeAnnotationPlugin(); if (this._chartConfig.chartType === eChartTypesString.DOUGHNUT) Chart.overrides.doughnut.cutout = '88%'; this.chart = new Chart(this.chartContext, { type: this._chartConfig?.chartType, data: this._chartConfig?.chartData, options: this._chartConfig?.chartOptions, plugins: this.plugins, }); if (!this._chartConfig.isDashboardChart) this.chart.config.data.datasets = [ ...this._chartConfig?.chartData?.datasets?.map( (item: IBaseDataset, indx: number) => { const pointConfig = { pointBackgroundColor: ChartColorsStringEnum.WHITE, pointBorderWidth: 2, pointRadius: 3, }; this.updateChartAnnotations(item, indx); const yScale = this.chart.scales[ EChartEventProperties.Y_AXIS_0 ] as CategoryScale | LinearScale; const backgroundColor = item?.color && item?.color2 ? ChartHelper.createGradient( this.chartContext, this.chartCanvas, item?.color, item?.color2 ) : item?.color; const datasetConfig = { ...item, pointBorderColor: item.color, ...pointConfig, backgroundColor, fill: item?.fill, }; if ( this._chartConfig.chartType === eChartTypesString.DOUGHNUT ) return { ...datasetConfig, backgroundColor: item.backgroundColor, }; if (!item.shiftValue) return { ...datasetConfig, backgroundColor: item?.color && item?.color2 && item?.color !== item?.color2 ? ChartHelper.createGradient( this.chartContext, this.chartCanvas, item?.color, item?.color2 ) : item?.color, }; const shiftValuePx: number = yScale.getPixelForValue( item.shiftValue || 0 ); const shiftValueAdjusted: number = shiftValuePx / yScale.maxHeight; const color1: string = item.color || ChartConstants.STRING_EMPTY; const color2: string = item.color2 || ChartConstants.STRING_EMPTY; const borderColor: CanvasGradient = ChartHelper.createGradientWithShiftValue( this.chartContext, yScale.maxHeight, color1, color2, shiftValueAdjusted ); return { ...item, ...pointConfig, pointBorderColor: borderColor, backgroundColor: ChartHelper.createGradientWithShiftValue( this.chartContext, yScale.maxHeight, color1, color2, shiftValueAdjusted, true ), borderColor, fill: true, }; } ), ]; this.chart.update(); } private updateChart(): void { if (this.chart) this.chart.update(); this.createChart(); } private setChartOptionsProperties(): void { const labels = this._chartConfig.chartData.labels; this._chartConfig.chartOptions = { responsive: true, maintainAspectRatio: false, clip: false, animation: { duration: 5, onComplete: () => { this.areAnimationsCompleated = true; this.chart.draw(); }, onProgress: () => { this.areAnimationsCompleated = false; this.chart.draw(); }, }, layout: { padding: { top: this._chartConfig.chartType === eChartTypesString.DOUGHNUT ? 8 : 0, bottom: this._chartConfig.chartType === eChartTypesString.DOUGHNUT ? 8 : 0, left: this._chartConfig.chartType === eChartTypesString.BAR && this._chartConfig.verticalyAlignBarChartWithLineCart ? 6 : 0, right: this._chartConfig.chartType === eChartTypesString.BAR && this._chartConfig.verticalyAlignBarChartWithLineCart ? 6 : 0, }, }, plugins: { legend: { display: false, }, tooltip: { enabled: false, }, }, scales: { x: { title: { display: true, }, position: 'bottom', grid: { display: false, }, ticks: { display: true, padding: 0, color: ChartColorsStringEnum.X_AXIS_LABEL_COLOR, font: { size: 11, family: ChartFontPropertiesStringEnum.FONT_FAMILY_MONTSERRAT, weight: ChartFontPropertiesStringEnum.FONT_WEIGHT_BOLDER, }, autoSkip: true, autoSkipPadding: 12, maxRotation: 0, minRotation: 0, //Label written in 2 lines, will be adjusted with new design callback: function ( value: string | number, index: number ): string | string[] { const label = labels[index]; const multiLineLabel = label ? label.split(' ') : []; return multiLineLabel; }, }, display: this._chartConfig.showXAxisLabels, beginAtZero: true, offset: true, }, y: { display: false, beginAtZero: true, min: 0, max: ChartHelper.calculateDatasetMaxValue( this._chartConfig.chartData.datasets as any //leave this as any ), offset: false, }, }, onHover: (event: ChartEvent, item: ActiveElement[]) => { if (this._chartConfig.hasVerticalDashedAnnotation) { this.removeVerticalDashedLine(); this.setVerticalDashedAnnotationLine(event); } this.setOriginalDoughnutArcsColor(); if ( this._chartConfig.chartType === eChartTypesString.DOUGHNUT ) { const index = item[0]?.index; index >= 0 ? this.focusDoughnutArc(index) : this.setOriginalDoughnutArcsColor(); this.hoveredIndex.emit(index ?? null); } this.chart.update(); }, }; } private focusDoughnutArc(index: number | null): void { if ( this._chartConfig.chartType !== eChartTypesString.DOUGHNUT || index === this.chart.data.datasets[0].data.length - 1 ) return; const colors: string[] = this.chart?.data?.datasets[0] ?.backgroundColor as string[]; const updatedColors: string[] = colors.map( (color: string, indx: number) => { if (indx === index) return color; const convertedValue = ChartHelper.convertRgbToRgba(color, 0.2); return convertedValue; } ); this.chart.data.datasets[0].backgroundColor = updatedColors; } private setOriginalDoughnutArcsColor(): void { if (this._chartConfig.chartType !== eChartTypesString.DOUGHNUT) return; const colors: string[] = this.chart.data.datasets[0] .backgroundColor as string[]; this.chart.data.datasets[0].backgroundColor = [ ...colors.map((color: string) => { return ChartHelper.rgbToRgba(color, 1); }), ]; this.chart.update(); } private setChartDataProperties(): void { if (this._chartConfig.chartType === eChartTypesString.LINE) { this._chartConfig.chartData.datasets.forEach((dataset) => { dataset.order = dataset.order; if (ChartTypeGuard.isLineDataset(dataset)) { dataset.tension = dataset.tension ?? 0.5; dataset.cubicInterpolationMode = dataset.cubicInterpolationMode ?? CubicInterpolationStringEnum.MONOTONE; dataset.pointBorderColor = dataset.pointBorderColor ?? ChartColorsStringEnum.TRANSPARENT; dataset.pointBackgroundColor = dataset.pointBackgroundColor ?? ChartColorsStringEnum.TRANSPARENT; dataset.pointHoverBackgroundColor = ChartColorsStringEnum.WHITE; dataset.pointHoverBorderColor = dataset.borderColor; dataset.pointBorderWidth = dataset.pointBorderWidth ?? 3; dataset.pointHoverRadius = 4; //dataset.pointHoverRadius = 8; dataset.pointHoverBorderWidth = 3; dataset.spanGaps = dataset.spanGaps ?? false; dataset.showLine = dataset.showLine ?? true; } }); this.setMultipleYAxis(this._chartConfig.chartData); } } private setChartPluginsProperties(): void { this.plugins = [ { id: ChartPluginIdsStringEnum.HIGHLIGHT_SEGMENT_ON_HOVER, afterEvent: (chart, event) => { const nativeEvent = event.event.native as MouseEvent; if ( event.event.type === ChartEventTypesStringEnum.MOUSE_OUT ) { const boundingRect = this.chart.canvas.getBoundingClientRect(); const isMouseOutsideChart = nativeEvent.clientX < boundingRect.left || nativeEvent.clientX > boundingRect.right || nativeEvent.clientY < boundingRect.top || nativeEvent.clientY > boundingRect.bottom; if (isMouseOutsideChart) { this._hoveredIndex = null; this.chartManagerService?.setHoverState( null, this.chartId ); this.removeVerticalDashedLineAndFocus(); this.chart.draw(); } return; } if (!this._chartConfig.showTooltipBackground) return; const boundingRect = this.chart.canvas.getBoundingClientRect(); const mouseHorizontalCoordinate = nativeEvent.clientX - boundingRect.left; const { left, right } = this.chart.chartArea; if ( mouseHorizontalCoordinate < left || mouseHorizontalCoordinate > right ) { this._hoveredIndex = null; this.chartManagerService?.setHoverState( null, this.chartId ); this.chart.draw(); return; } const xScale = this.chart.scales[ EChartEventProperties.X ] as CategoryScale | LinearScale; const tickPositions = xScale.ticks.map((tick) => xScale.getPixelForValue(tick.value) ); const segmentBoundaries = tickPositions.map( (tickPos, index) => { const leftBoundary = index === 0 ? left : (tickPos + tickPositions[index - 1]) / 2; const rightBoundary = index === tickPositions.length - 1 ? right : (tickPos + tickPositions[index + 1]) / 2; return { leftBoundary, rightBoundary }; } ); const newHoveredIndex = segmentBoundaries.findIndex( (boundary) => mouseHorizontalCoordinate >= boundary.leftBoundary && mouseHorizontalCoordinate < boundary.rightBoundary ); if (newHoveredIndex !== this._hoveredIndex) { this._hoveredIndex = newHoveredIndex === -1 ? null : newHoveredIndex; this.chartManagerService?.setHoverState( this._hoveredIndex, this.chartId, segmentBoundaries[this._hoveredIndex as number] ); this.chart.draw(); } }, beforeDraw: () => { if (this._hoveredIndex !== null) { const { chartArea, scales: { x: xScale }, } = this.chart; ChartHelper.highlightSegment( this.chartContext, xScale as CategoryScale | LinearScale, chartArea, this._chartConfig.height, this._hoveredIndex ); const activeData = this._chartConfig?.chartData?.datasets?.map( (data, index) => { return { datasetIndex: index, index: this._hoveredIndex, }; } ); if (activeData.length) { this.chart.setActiveElements( activeData as ActiveDataPoint[] ); } } }, }, { id: ChartPluginIdsStringEnum.BORDER_BOTTOM_LINE_CHART, beforeDraw: () => { if (this._chartConfig?.showBottomLineOnLineChart) { const { ctx, data, chartArea, scales: { x: xScale }, } = this.chart; const dataset = data.datasets[0]; const firstIndex = 0; const lastIndex = dataset.data.length - 1; const firstValue = dataset.data[firstIndex]; const lastValue = dataset.data[lastIndex]; if ( firstValue !== undefined && lastValue !== undefined ) { const xStart = xScale.getPixelForValue(firstIndex); const xEnd = xScale.getPixelForValue(lastIndex); const yBottom = chartArea.bottom; ctx.save(); ctx.strokeStyle = ChartColorsStringEnum.BORDER_BOTTOM_LINE_CHART_COLOR; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(xStart, yBottom); ctx.lineTo(xEnd, yBottom); ctx.stroke(); ctx.restore(); } } }, }, ]; if (this._chartConfig.chartType === eChartTypesString.DOUGHNUT) this.plugins = [ ...this.plugins, { id: 'doughnut-label', beforeDatasetDraw: (chart: Chart) => { this._chartConfig.centerLabels?.map( (label: IChartCenterLabel, indx: number) => { ChartHelper.drawDoughnutLegend( chart, { indx, offsetTop: label.position?.top || 0, }, label.value, label.fontSize, label.color ); } ); }, }, ]; } private updateChartAnnotations(item: IBaseDataset, indx: number): void { if (!this._chartConfig.annotations?.length) return; this._chartConfig.annotations?.forEach( (annotation: IChartAnnotation, key: number) => { this._chartConfig.chartOptions.plugins = { ...this._chartConfig.chartOptions.plugins, annotation: { ...this._chartConfig.chartOptions?.plugins?.annotation, annotations: { ...this._chartConfig.chartOptions?.plugins ?.annotation?.annotations, [`${item.label}-${indx}-${key}`]: { id: annotation.id || `annotation-${indx}-${key}`, type: annotation.type, scaleID: annotation.axis || item.yAxisID, value: annotation.value || item.shiftValue, borderColor: annotation.color || ChartColorsStringEnum.BORDER_BOTTOM_LINE_CHART_COLOR, borderWidth: annotation.borderWidth || 2, borderDash: annotation.borderDash || [0, 0], }, }, }, }; } ); this.chart.update(); } private setMultipleYAxis(chartData: IChartData): void { chartData.datasets.forEach((dataset, index) => { if ( dataset.type === eChartTypesString.LINE || dataset.type === eChartTypesString.BAR ) { const yAxisID = this._chartConfig.isMultiYAxis ? `y-axis-${index}` : dataset.yAxisID || 'y-axis-0'; // Detect if the dataset is all 0 const isAllZeroDataset = (dataset.data as number[]).every( (val: number) => val === 0 ); let minValue = ChartHelper.calculateMinValue( dataset.data as number[] ); let maxValue = ChartHelper.calculateMaxValue( dataset.data as number[] ); const padding = maxValue * 0.03; // Adjust scaling for all 0 datasets if (isAllZeroDataset) { minValue = 0; maxValue = 1; } else { minValue = maxValue === minValue ? minValue - 0.1 : minValue; maxValue = maxValue === minValue ? maxValue + 0.1 : maxValue + padding; } this._chartConfig.chartOptions.scales = { ...this._chartConfig.chartOptions.scales, [yAxisID]: { display: false, beginAtZero: true, min: minValue, max: maxValue, stacked: this._chartConfig.isStacked, }, }; chartData.datasets[index] = { ...dataset, yAxisID, }; } }); } private removeVerticalDashedLine(): void { const annotations = this._chartConfig?.chartOptions?.plugins?.annotation?.annotations; const key: string = ChartPluginIdsStringEnum.X_DASHED; if (annotations) delete this._chartConfig?.chartOptions?.plugins?.annotation ?.annotations?.[key as keyof typeof annotations]; this.chart.update(); } private setVerticalDashedAnnotationLine( event: ChartEvent, color?: string ): void { const { left, right } = this.chart.chartArea; const xScale = this.chart.scales[EChartEventProperties.X] as | CategoryScale | LinearScale; const tickPositions = this._chartConfig?.chartData?.labels?.map( (label: string, index: number) => { return xScale.getPixelForValue(index); } ); const segmentBoundaries = tickPositions.map((tickPos, index) => { const leftBoundary = index === 0 ? left : (tickPos + tickPositions[index - 1]) / 2; const rightBoundary = index === tickPositions.length - 1 ? right : (tickPos + tickPositions[index + 1]) / 2; return { leftBoundary, rightBoundary }; }); const nativeEvent = event.native as MouseEvent; const boundingRect = this.chart.canvas.getBoundingClientRect(); const mouseHorizontalCoordinate = nativeEvent.clientX - boundingRect.left; const newHoveredIndex = segmentBoundaries.findIndex( (boundary) => mouseHorizontalCoordinate >= boundary.leftBoundary && mouseHorizontalCoordinate < boundary.rightBoundary ); if (newHoveredIndex >= 0) { this._chartConfig.chartOptions.plugins = { ...this._chartConfig.chartOptions.plugins, annotation: { ...this._chartConfig.chartOptions?.plugins?.annotation, annotations: { ...this._chartConfig.chartOptions?.plugins?.annotation ?.annotations, [ChartPluginIdsStringEnum.X_DASHED]: { ...EChartAnnotation.X_DASHED, value: newHoveredIndex, borderColor: color || ChartColorsStringEnum.BORDER_BOTTOM_LINE_CHART_COLOR, }, }, }, }; } this.hoveredIndex.emit(newHoveredIndex); this.chart.update(); } public removeVerticalDashedLineAndFocus(): void { this.hoveredIndex.emit(null); if (this._chartConfig.hasVerticalDashedAnnotation) this.removeVerticalDashedLine(); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } }