import Store from '@src/store/store' ; import root from '@src/store/root' ; import layout from '@src/store/layout' ; import seriesData from '@src/store/seriesData' ; import category from '@src/store/category' ; import legend from '@src/store/legend' ; import optionsStore from '@src/store/options' ; import theme from '@src/store/theme' ; import EventEmitter from '@src/eventEmitter' ; import ComponentManager from '@src/component/componentManager' ; import Painter from '@src/painter' ; import Animator from '@src/animator' ; import { debounce , isBoolean , isNumber , isUndefined , pick , isAutoValue } from '@src/helpers/utils' ; import { ChartProps , Point , AnimationOptions , SeriesDataInput , Size , DataInput , ChartSizeInput , } from '@t/options' ; import { RespondersModel } from '@src/component/component' ; import { responderDetectors } from '@src/responderDetectors' ; import { ChartState , Options , StoreModule , UsingContainerSize } from '@t/store/store' ; import Component from '@src/component/component' ; import { CheckedLegendType } from '@t/components/legend' ; import { message } from '@src/message' ; import { sendHostname } from '@src/helpers/googleAnalytics' ; import { makeObservableObjectToNormal } from '@src/store/reactive' ; import { SelectSeriesInfo , AddSeriesDataInfo } from '@t/charts' ; import { CustomEventType , EventListener } from '@t/eventEmitter' ; import { isMouseInRect } from '@src/helpers/coordinate' ; export const DEFAULT_ANIM_DURATION = 500 ; export interface SelectSeriesHandlerParams < T extends Options > extends SelectSeriesInfo { state : ChartState < T > ; } function getUsingContainerSize ( eventName , usingContainerSize , width ? , height ? ) { const { width : usingContainerWidth , height : usingContainerHeight } = usingContainerSize ; const isAutoWidth = isAutoValue ( width ) ; const isAutoHeight = isAutoValue ( height ) ; return eventName === 'updateOptions' ? { width : ! isUndefined ( width ) && usingContainerWidth !== isAutoWidth ? isAutoWidth : usingContainerWidth , height : ! isUndefined ( height ) && usingContainerHeight !== isAutoHeight ? isAutoHeight : usingContainerHeight , } : { width : isAutoWidth , height : isAutoHeight , } ; } class abstract export default abstract class Chart < T extends Options > { store : Store < T > ; ___animId___ = null ; animator : Animator ; readonly containerEl : HTMLElement ; el : HTMLDivElement ; ctx ! : CanvasRenderingContext2D ; painter = new Painter ( this ) ; readonly eventBus : EventEmitter = new EventEmitter ( ) ; readonly componentManager : ComponentManager < T > ; modules : StoreModule [ ] ; enteredComponents : Component [ ] = [ ] ; animationControlFlag = { resizing : false , updating : false , } ; resizeObserver : ResizeObserver | null = null ; private getAnimationDuration ( animationOption ? ) { const { firstRendering } = this . animator ; const { resizing , updating } = this . animationControlFlag ; let duration ; if ( ( ! firstRendering && ! resizing ) || isUndefined ( animationOption ) ) { duration = DEFAULT_ANIM_DURATION ; } else if ( isBoolean ( animationOption ) ) { duration = animationOption ? DEFAULT_ANIM_DURATION : 0 ; } else if ( isNumber ( animationOption . duration ) ) { duration = animationOption . duration ; } if ( updating ) { duration = 0 ; } this . animationControlFlag . updating = false ; return duration ; } createChartWrapper ( ) { const el = document . createElement ( 'div' ) ; el . classList . add ( 'toastui-chart-wrapper' ) ; return el ; } constructor ( props ) { const { el , options , series , categories , modules } = props ; this . modules = modules ?? [ ] ; if ( isUndefined ( options . usageStatistics ) || options . usageStatistics ) { sendHostname ( ) ; } this . containerEl = el ; this . el = this . createChartWrapper ( ) ; this . containerEl . appendChild ( this . el ) ; this . animator = new Animator ( ) ; this . store = new Store ( { series , categories , options , } ) ; this . componentManager = new ComponentManager ( { store : this . store , eventBus : this . eventBus , } ) ; this . eventBus . on ( 'needLoop' , debounce ( ( ) => { let duration = this . getAnimationDuration ( options . chart ?. animation ) ; if ( this . animationControlFlag . resizing ) { duration = isUndefined ( options . responsive ) ? this . getAnimationDuration ( ) : this . getAnimationDuration ( options . responsive ?. animation ) ; this . animationControlFlag . resizing = false ; } this . eventBus . emit ( 'loopStart' ) ; this . animator . add ( { onCompleted : ( ) => { this . eventBus . emit ( 'loopComplete' ) ; } , chart : this , duration , requester : this , } ) ; } , 10 ) ) ; this . eventBus . on ( 'needSubLoop' , ( opts ) => { this . animator . add ( { ... opts , chart : this } ) ; } ) ; this . eventBus . on ( 'needDraw' , debounce ( ( ) => { this . draw ( ) ; } , 10 ) ) ; this . initialize ( ) ; this . store . observe ( ( ) => { this . painter . setup ( ) ; } ) ; if ( isAutoValue ( options ?. chart ?. width ) || isAutoValue ( options ?. chart ?. height ) ) { this . setResizeEvent ( ) ; } } resizeChartSize ( containerWidth ? , containerHeight ? ) { this . animationControlFlag . resizing = true ; const { usingContainerSize : { width : usingContainerWidth , height : usingContainerHeight } , chart : { width , height } , } = this . store . state ; if ( ! ( usingContainerWidth || usingContainerHeight ) || ! ( containerWidth || containerHeight ) || ( containerWidth === width && containerHeight === height ) ) { this . animationControlFlag . resizing = false ; return ; } this . resetSeries ( ) ; this . store . dispatch ( 'setChartSize' , { width : usingContainerWidth ? containerWidth : width , height : usingContainerHeight ? containerHeight : height , } ) ; this . draw ( ) ; } private debounceResizeEvent = debounce ( ( ) => { const { offsetWidth , offsetHeight } = this . containerEl ; this . resizeChartSize ( offsetWidth , offsetHeight ) ; } , 100 ) ; setResizeEvent ( ) { const { usingContainerSize } = this . store . state ; if ( ( usingContainerSize . height && ! this . containerEl . style . height . length ) || ( usingContainerSize . width && ! this . containerEl . style . width . length ) ) { throw new Error ( message . AUTO_LAYOUT_CONTAINER_SIZE_ERROR ) ; } const isResizeObserverAPIExist = typeof ResizeObserver === 'undefined' ; if ( isResizeObserverAPIExist ) { window . addEventListener ( 'resize' , this . debounceResizeEvent ) ; } else { this . resizeObserver = new ResizeObserver ( ( entries ) => { entries . forEach ( ( ) => { this . debounceResizeEvent ( ) ; } ) ; } ) ; this . resizeObserver . observe ( this . containerEl ) ; } } clearResizeEvent ( ) { if ( this . resizeObserver ) { this . resizeObserver . unobserve ( this . containerEl ) ; this . resizeObserver . disconnect ( ) ; this . resizeObserver = null ; } else { window . removeEventListener ( 'resize' , this . debounceResizeEvent ) ; } } handleCanvasMouseEvent ( eventType , mousePosition ) { const newEnteredComponents = [ ] ; this . componentManager . forEach ( ( component ) => { if ( eventType === 'mousemove' ) { const exist = this . enteredComponents . some ( ( enteredComponent ) => enteredComponent === component ) ; if ( isMouseInRect ( component . rect , mousePosition ) ) { newEnteredComponents . push ( component ) ; if ( ! exist && component . onMouseenterComponent ) { component . onMouseenterComponent ( ) ; } } else if ( exist && component . onMouseoutComponent ) { component . onMouseoutComponent ( ) ; } } else if ( eventType === 'mouseout' && component . onMouseoutComponent ) { component . onMouseoutComponent ( ) ; } } ) ; this . enteredComponents = newEnteredComponents ; } handleResponderEvent ( event , mousePosition ) { const eventType = event . type ; const delegationMethod = `on${ eventType [ 0 ] . toUpperCase ( ) + eventType . substring ( 1 ) }` ; const allResponders = [ ] ; this . componentManager . forEach ( ( component ) => { if ( ! component [ delegationMethod ] ) { return ; } if ( ! responderDetectors . rect ( mousePosition , component . rect ) ) { return ; } const detected = ( component . responders || [ ] ) . filter ( ( m ) => { return responderDetectors [ m . type ] ( mousePosition , m , component . rect ) ; } ) ; if ( detected . length ) { allResponders . push ( { component , detected } ) ; } component [ delegationMethod ] ( { mousePosition , responders : detected } , event ) ; } ) ; if ( this . handleEventForAllResponders ) { this . handleEventForAllResponders ( event , allResponders , delegationMethod , mousePosition ) ; } } handleEvent ( event ) { const { clientX , clientY , type : eventType } = event ; const canvas = this . painter . ctx . canvas ; const { width , height , left , top } = canvas . getBoundingClientRect ( ) ; const scaleX = width / canvas . offsetWidth ; const scaleY = height / canvas . offsetHeight ; const mousePosition = { x : ( clientX - left ) / scaleX , y : ( clientY - top ) / scaleY , } ; if ( eventType === 'mousemove' || eventType === 'mouseout' ) { this . handleCanvasMouseEvent ( eventType , mousePosition ) ; } this . handleResponderEvent ( event , mousePosition ) ; } protected initStore ( ) { [ root , optionsStore , theme , seriesData , legend , layout , category , ... this . modules , ] . forEach ( ( module ) => this . store . setModule ( module ) ) ; } protected initialize ( ) { this . initStore ( ) ; this . store . dispatch ( 'initChartSize' , this . containerEl ) ; } draw ( ) { this . painter . beforeFrame ( ) ; this . componentManager . forEach ( ( component ) => { if ( ! component . isShow ) { return ; } this . painter . beforeDraw ( component . rect . x , component . rect . y ) ; if ( component . beforeDraw ) { component . beforeDraw ( this . painter ) ; } component . draw ( this . painter ) ; this . painter . afterDraw ( ) ; } ) ; } update ( delta ) { this . componentManager . invoke ( 'update' , delta ) ; } initUpdate ( delta ) { this . componentManager . invoke ( 'initUpdate' , delta ) ; } protected handleEventForAllResponders ? ( event , responderModels , delegationMethod , mousePosition ) ; returns { Array < { checked : boolean , chartType : string , label : string } > } api example public getCheckedLegend = ( ) : CheckedLegendType => { const { data } = this . store . state . legend ; return data . filter ( ( datum ) => datum . checked ) . map ( ( datum ) => pick ( datum , 'chartType' , 'label' , 'checked' ) ) ; } ; public abstract updateOptions ( options ) ; public abstract setOptions ( options ) ; public abstract showTooltip ( info ) ; public abstract hideTooltip ( ) ; returns { Object } api example public getOptions = ( ) => { return makeObservableObjectToNormal ( this . store . initStoreState . options ) ; } ; public abstract addSeries ( data , dataInfo ? ) ; param { string } eventName param { Function } handler api public on = ( eventName , handler ) => { event param { object } info api example event param { object } info api example event param { object } info api example event param { object } info api example event param { object } info api example event param { object } info api example event param { string [ ] } dataRange api example event api example this . eventBus . on ( eventName , handler ) ; } ; public abstract setData ( data ) ; api example public destroy = ( ) => { this . componentManager . clear ( ) ; this . clearResizeEvent ( ) ; this . containerEl . innerHTML = '' ; } ; private isSelectableSeries ( ) { return this . store . initStoreState . options . series ?. selectable ; } param seriesInfo param { number } seriesInfo . seriesIndex param { number } seriesInfo . index param { string } seriesInfo . name param { string } seriesInfo . chartType api example public selectSeries = ( seriesInfo ) => { if ( ! this . isSelectableSeries ( ) ) { throw new Error ( message . SELECT_SERIES_API_SELECTABLE_ERROR ) ; } this . eventBus . emit ( 'selectSeries' , { ... seriesInfo , state : this . store . state } ) ; } ; api example public unselectSeries = ( ) => { if ( ! this . isSelectableSeries ( ) ) { throw new Error ( message . SELECT_SERIES_API_SELECTABLE_ERROR ) ; } this . store . dispatch ( 'setAllLegendActiveState' , true ) ; this . eventBus . emit ( 'resetSelectedSeries' ) ; } ; param size param { number } size . width param { number } size . height api example public resize = ( size ) => { this . resetSeries ( ) ; this . dispatchOptionsEvent ( 'updateOptions' , { chart : { ... size } } ) ; } ; param offset param { number } offset . x param { number } offset . y api example public setTooltipOffset ( offset ) { const { x : offsetX , y : offsetY } = offset ; this . store . dispatch ( 'updateOptions' , { options : { tooltip : { offsetX , offsetY } } } ) ; } resetSeries = ( ) => { this . eventBus . emit ( 'resetHoveredSeries' ) ; this . eventBus . emit ( 'resetSelectedSeries' ) ; } ; private setResizeEventListeners = ( eventName , options ) => { const { usingContainerSize } = this . store . state ; const { width : usingContainerWidth , height : usingContainerHeight } = usingContainerSize ; const width = options ?. chart ?. width ; const height = options ?. chart ?. height ; const isAutoWidth = isAutoValue ( width ) ; const isAutoHeight = isAutoValue ( height ) ; this . store . dispatch ( 'setUsingContainerSize' , getUsingContainerSize ( eventName , usingContainerSize , width , height ) ) ; if ( ( usingContainerWidth || usingContainerHeight ) && isNumber ( width ) && isNumber ( height ) ) { this . clearResizeEvent ( ) ; } else if ( ! ( usingContainerWidth || usingContainerHeight ) && ( isAutoWidth || isAutoHeight ) ) { this . setResizeEvent ( ) ; } } ; protected dispatchOptionsEvent ( eventName , options ) { this . setResizeEventListeners ( eventName , options ) ; const { offsetWidth , offsetHeight } = this . containerEl ; this . store . dispatch ( eventName , { options , containerSize : { width : offsetWidth , height : offsetHeight } , } ) ; } }