import { ElementRef , Injectable } from '@angular/core' ; import { ActiveElement , ChartConfiguration , ChartOptions , ScatterDataPoint } from 'chart.js' ; import { AnnotationOptions } from 'chartjs-plugin-annotation' ; import { toDate } from 'date-fns' ; import { mergeDeepAll } from '../../../helpers/merge-deep' ; import { ChartDataLabelOptions , ChartDataset , ChartHighlightedElements , ChartLocale , ChartType , isNumberArray , } from '../chart.types' ; import { ChartConfigService } from '../configs/chart-config.service' ; import { Chart } from './configured-chart-js' ; const CHART_LOCALE_DEFAULT = 'en-US' ; @ Injectable ( ) export class ChartJSService { private chart : Chart ; private dataLabelOptions : ChartDataLabelOptions ; private highlightedElements : ChartHighlightedElements ; private chartType : ChartType ; private locale : ChartLocale ; constructor ( private chartConfigService ) { } public renderChart ( args ) { const { targetElement , type , data , dataLabels , customOptions , annotations , dataLabelOptions , highlightedElements , } = args ; this . dataLabelOptions = dataLabelOptions || null ; this . highlightedElements = highlightedElements || null ; this . chartType = type ; this . locale = dataLabelOptions ?. locale || CHART_LOCALE_DEFAULT ; const datasets = this . createDatasets ( data ) ; const options = this . createOptionsObject ( { customOptions , annotations , dataLabelOptions , } ) ; let config = this . createConfigurationObject ( type , datasets , options , dataLabels ) ; this . initializeNewChart ( targetElement . nativeElement , config ) ; } public redrawChart ( ) { this . chart . update ( ) ; } public updateData ( data ) { const datasets = this . createDatasets ( data ) ; this . chart . data . datasets = datasets ; } public updateDataLabels ( dataLabels ) { this . chart . data . labels = dataLabels ; } public updateType ( type , customOptions ? ) { if ( type === 'bar' || type === 'column' ) { this . destructivelyUpdateType ( type , customOptions ) ; } else { this . nonDestructivelyUpdateType ( type , customOptions ) ; } } public setDataLabelOptions ( dataLabelOptions ) { this . dataLabelOptions = dataLabelOptions ; } public updateOptions ( customOptions , type ) { const annotations = this . getExistingChartAnnotations ( ) ; this . chartType = type ; this . chart . options = this . createOptionsObject ( { customOptions , annotations } ) ; } public updateAnnotations ( annotations ) { const annotationsWithDefaults = this . applyDefaultsToAnnotations ( annotations ) ; this . chart . options . plugins . annotation . annotations = annotationsWithDefaults ; } public updateHighlightedElements ( highlightedElements ? ) { this . highlightedElements = highlightedElements ; const oldDatasets = this . chart . data . datasets as ChartDataset [ ] ; oldDatasets . map ( ( dataset ) => { if ( dataset ?. kirbyOptions ?. highlightedElements ) { delete dataset . kirbyOptions . highlightedElements ; } } ) ; this . chart . data . datasets = this . createDatasets ( oldDatasets ) ; } private getExistingChartAnnotations ( ) { const annotations = this . chart . options . plugins ?. annotation ?. annotations ; if ( annotations !== undefined ) { return Object . keys ( annotations ) . map ( ( key ) => annotations [ key ] ) ; } else { return [ ] ; } } private destructivelyUpdateType ( type , customOptions ? ) { const datasets = this . chart . data . datasets as ChartDataset [ ] ; const dataLabels = this . chart . data . labels ; const annotations = this . getExistingChartAnnotations ( ) ; this . chartType = type ; const options = this . createOptionsObject ( { customOptions , annotations } ) ; const config = this . createConfigurationObject ( type , datasets , options , dataLabels ) ; const canvasElement = this . chart . canvas ; this . chart . destroy ( ) ; this . initializeNewChart ( canvasElement , config ) ; } private nonDestructivelyUpdateType ( chartType , customOptions ? ) { const annotations = this . getExistingChartAnnotations ( ) ; this . chartType = chartType ; const options = this . createOptionsObject ( { customOptions , annotations , } ) ; this . chart . options = options ; this . chart . config . type = this . chartConfigService . chartTypeToChartJSType ( chartType ) ; } private initializeNewChart ( canvasElement , config ) { this . chart = new Chart ( canvasElement , config ) ; } private createBlankLabels ( datasets ) { const largestDataset = datasets . reduce ( ( previousDataset , currentDataset ) => previousDataset . data . length > currentDataset . data . length ? previousDataset : currentDataset ) ; return Array ( largestDataset . data . length ) . fill ( '' ) ; } private applyDefaultsToAnnotations ( annotations ) { return annotations . map ( ( annotation ) => { const annotationTypeDefaults = this . chartConfigService . getAnnotationDefaults ( annotation . type ) ; return mergeDeepAll ( annotationTypeDefaults , annotation ) ; } ) ; } private createAnnotationPluginOptionsObject ( annotations ) { const annotationsWithDefaults = this . applyDefaultsToAnnotations ( annotations ) ; return { plugins : { annotation : { annotations : annotationsWithDefaults , } , } , } ; } private applyInteractionFunctionsExtensions ( options ) { const interactionFunctionsExtensions = this . chartConfigService . getInteractionFunctionsExtensions ( ) ; Object . entries ( interactionFunctionsExtensions ) . forEach ( ( [ key , _ ] ) => { const callback = options [ key ] ; options [ key ] = ( e , a , c ) => { interactionFunctionsExtensions [ key ] ( e , a , c , callback ) ; } ; } ) ; return options ; } private createStockOptionsObject ( dataLabelOptions ) { return { locale : this . locale , plugins : { tooltip : { callbacks : { title : ( tooltipItems ) => { const date = toDate ( ( tooltipItems [ 0 ] ?. raw as any ) ?. x ) ; if ( date . valueOf ( ) ) { return date . toLocaleTimeString ( this . locale , { day : 'numeric' , month : 'short' , hour : '2-digit' , minute : '2-digit' , } ) ; } } , label : ( context ) => { return ' ' + context . formattedValue + ( dataLabelOptions . valueSuffix || '' ) ; } , } , } , } , scales : { y : { ticks : { callback : ( value ) => { return value + ( dataLabelOptions . valueSuffix || '' ) ; } , } , } , } , } ; } private createOptionsObject ( args ) { const { customOptions , annotations , dataLabelOptions : chartDataLabelOptions } = args ; const typeConfig = this . chartConfigService . getTypeConfig ( this . chartType ) ; const typeConfigOptions = typeConfig ?. options ; const annotationPluginOptions = annotations ? this . createAnnotationPluginOptionsObject ( annotations ) : { } ; const stockOptions = this . chartType === 'stock' ? this . createStockOptionsObject ( chartDataLabelOptions ) : { } ; let options = mergeDeepAll ( stockOptions , typeConfigOptions , customOptions , annotationPluginOptions ) ; return this . applyInteractionFunctionsExtensions ( options ) ; } private getDefaultStockLabels ( datasets , locale ) { const largestDataset = datasets . reduce ( ( previousDataset , currentDataset ) => previousDataset . data . length > currentDataset . data . length ? previousDataset : currentDataset ) ; return largestDataset . data . map ( ( point ) => toDate ( point . x ) . toLocaleDateString ( locale , { month : 'short' , day : 'numeric' , } ) ) ; } private createConfigurationObject ( type , datasets , options , dataLabels ? ) { const typeConfig = this . chartConfigService . getTypeConfig ( type ) ; const isStockType = type === 'stock' ; const labels = ! dataLabels ? isStockType ? this . getDefaultStockLabels ( datasets , this . locale ) : this . createBlankLabels ( datasets ) : dataLabels ; return mergeDeepAll ( typeConfig , { data : { labels , datasets , } , options , } ) as ChartConfiguration ; } private addHighlightedElementsToDatasets ( highlightedElements , datasets ) { highlightedElements . forEach ( ( [ datasetIndex , dataIndex ] ) => { const dataset = datasets [ datasetIndex ] ; if ( ! dataset ) return ; if ( dataset ?. kirbyOptions ?. highlightedElements ) { dataset . kirbyOptions . highlightedElements . push ( dataIndex ) ; } else { dataset . kirbyOptions = { ... dataset . kirbyOptions , highlightedElements : [ dataIndex ] , } ; } } ) ; } private createDatasets ( data ) { if ( this . dataLabelOptions ?. showCurrent || this . dataLabelOptions ?. showMax || this . dataLabelOptions ?. showMin ) { data = this . addDataLabelsData ( data ) ; } let datasets = isNumberArray ( data ) ? [ { data } ] : data ; if ( this . highlightedElements ) this . addHighlightedElementsToDatasets ( this . highlightedElements , datasets ) ; if ( this . chartType === 'stock' ) { datasets . forEach ( ( dataset ) => { dataset . kirbyOptions = { ... dataset . kirbyOptions , isStockChart : true , } ; } ) ; } return datasets ; } param data returns public addDataLabelsData ( data ) { if ( isNumberArray ( data ) ) { throw Error ( "Currently it's impossible to add dataLabels to non ScatterDataPoint datasets" ) ; } const decorateDataPoint = ( set , axis , direction , position ) : void => { const { value , pointer } = this . locateValueIndexInDataset ( set , axis , direction ) ; set . data [ pointer ] = { ... ( set . data [ pointer ] as ScatterDataPoint ) , datalabel : { value : value + ( this . dataLabelOptions . valueSuffix || '' ) , position , } , } as ScatterDataPoint ; } ; data . map ( ( set ) => { if ( this . dataLabelOptions . showMin ) { decorateDataPoint ( set , 'y' , 'low' , 'bottom' ) ; } if ( this . dataLabelOptions . showMax ) { decorateDataPoint ( set , 'y' , 'high' , 'top' ) ; } if ( this . dataLabelOptions . showCurrent ) { decorateDataPoint ( set , 'x' , 'high' , 'right' ) ; } } ) ; return data ; } private locateValueIndexInDataset ( dataset , axis , direction ) { let pointer ; let value ; dataset . data . forEach ( ( datapoint , index ) => { if ( direction == 'low' && ( ! value || datapoint [ axis ] < value ) ) { value = datapoint [ 'y' ] ; pointer = index ; } if ( direction == 'high' && ( ! value || datapoint [ axis ] > value ) ) { value = datapoint [ 'y' ] ; pointer = index ; } } ) ; return { value , pointer } ; } }