components/WEchartsVue.vue

<template>
    <!-- ref=$self為供外部存取繪圖容器之用; 寬高由外部以style給予 -->
    <div ref="$self" class="w-echarts-vue" v-domresize="resizeOpt" @domresize="onDomResize"></div>
</template>

<script>
import domResize from 'w-component-vue/src/js/domResize.mjs'
import iniEcharts from '../js/iniEcharts.mjs'
import parseAutoresize from '../js/parseAutoresize.mjs'
import isPropUnchanged from '../js/isPropUnchanged.mjs'
import getContainerSize from '../js/getContainerSize.mjs'


//echarts, 直接引用echarts與echarts-gl不使用cdn
//注意: 本組件直接以echarts原生API實作生命週期(init, setOption, resize, dispose)與事件轉發,
//不使用vue-echarts, 因其runtime依賴vue-demi, 而vue-demi出廠為Vue3模式須靠postinstall切換,
//npm>=12/pnpm>=10/bun預設封鎖依賴套件install scripts, 下游以src方式引用本套件時會靜默停在Vue3模式而壞掉;
//直接實作後全依賴樹零install script, 下游任意套件管理器與打包器皆零設定可用
let echarts = iniEcharts()


/**
 * echarts繪圖組件, 已內含echarts全量圖表與組件, 以及echarts-gl之3D圖表(bar3D, line3D, scatter3D, surface等), 不須自cdn額外引用
 *
 * 注意: 組件本身不帶預設高度, 須由外部給予寬高(例如style="width:620px; height:300px;"), 否則因高度為0而看不見圖形
 *
 * @vue-prop {Object} [options={}] 輸入echarts設定物件,即echarts之option,預設{}。變更時自動重繪:給予新物件(參照改變)採整體置換(notMerge),原物件內變更(mutation)採合併更新,與vue-echarts語意一致
 * @vue-prop {String|Object} [theme=''] 輸入echarts主題字串或主題設定物件,字串為已使用echarts.registerTheme註冊之主題名稱,變更時會自動銷毀重建圖表,內容相同之新物件(例如模板物件字面值於每次重繪產生者)不視為變更,預設''
 * @vue-prop {Object} [initOptions={}] 輸入echarts初始化設定物件,即echarts.init之opts,可設定renderer('canvas'或'svg')、locale、devicePixelRatio等,變更時會自動銷毀重建圖表,內容相同之新物件不視為變更,預設{}
 * @vue-prop {Object} [updateOptions={}] 輸入echarts更新設定物件,即echarts之setOption之opts,可設定notMerge、replaceMerge、lazyUpdate等,預設{}
 * @vue-prop {String} [group=''] 輸入圖表群組名稱字串,供echarts.connect跨圖表連動使用,預設''
 * @vue-prop {Boolean} [manualUpdate=false] 輸入是否關閉options之深度監聽布林值,若為true則變更options不會自動重繪,須自行取得實例呼叫setOption更新,適用於資料量龐大時,預設false
 * @vue-prop {Boolean} [loading=false] 輸入是否顯示讀取中動畫布林值,預設false
 * @vue-prop {Object} [loadingOptions={}] 輸入讀取中動畫設定物件,即echarts之showLoading之opts,可設定text、color、maskColor等,內容相同之新物件不視為變更,預設{}
 * @vue-prop {Boolean|Object} [autoresize=true] 輸入是否於容器尺寸改變時自動重繪布林值,亦可給物件設定{throttle,onResize},throttle為節流毫秒數(取整,0為不節流)預設100,onResize為圖表依新尺寸重繪後之通知函數(呼叫時已完成繪製,可直接讀取新版面),容器尺寸與圖表一致或容器隱藏(寬或高為0)時不重繪亦不通知,僅啟用狀態或throttle改變時才重新綁定監聽,預設true
 * @vue-event {Object} * 對外轉發echarts之全部事件,例如click、mouseover、legendselectchanged、datazoom、finished等,回傳事件參數物件;zrender事件以zr:前綴綁定,例如@zr:click
 */
export default {
    directives: {
        domresize: domResize(),
    },
    props: {
        options: {
            type: Object,
            default: () => {},
        },
        theme: {
            type: [String, Object],
            default: () => '',
        },
        initOptions: {
            type: Object,
            default: () => {},
        },
        updateOptions: {
            type: Object,
            default: () => {},
        },
        group: {
            type: String,
            default: () => '',
        },
        manualUpdate: {
            type: Boolean,
            default: () => false,
        },
        loading: {
            type: Boolean,
            default: () => false,
        },
        loadingOptions: {
            type: Object,
            default: () => {},
        },
        autoresize: {
            type: [Boolean, Object],
            default: () => true,
        },
    },
    data: function() {
        //chart為echarts實例, 不可放入data, 否則Vue會對其深層響應化(遞迴defineProperty), 造成效能問題, 故於init時直接掛this成非響應式屬性
        //resizeOpt為v-domresize之設定, 僅於autoresize之啟用狀態或throttle改變時更換
        return {
            resizeOpt: this.genResizeOpt(this.autoresize),
        }
    },
    mounted: function() {
        //console.log('mounted')

        let vo = this

        //init
        vo.init()

    },
    beforeDestroy: function() {
        //console.log('beforeDestroy')

        let vo = this

        //clear
        vo.clear()

    },
    watch: {

        options: {
            deep: true,
            handler: function(nv, ov) {
                let vo = this

                //manualUpdate, 關閉自動重繪
                if (vo.manualUpdate) {
                    return
                }

                //check
                if (!vo.chart) {
                    return
                }

                //setOption, 參照改變(給予新物件)採整體置換, 原物件內變更採合併更新, 與vue-echarts語意一致
                vo.chart.setOption(nv || {}, { notMerge: nv !== ov, ...vo.updateOptions })

            },
        },

        theme: function(nv, ov) {
            //模板物件字面值每次重繪皆為新物件, 內容相同時不重建
            if (isPropUnchanged(nv, ov)) {
                return
            }
            this.reinit()
        },

        initOptions: {
            deep: true,
            handler: function(nv, ov) {
                //模板物件字面值每次重繪皆為新物件, 內容相同時不重建
                if (isPropUnchanged(nv, ov)) {
                    return
                }
                this.reinit()
            },
        },

        group: function(nv) {
            let vo = this
            if (vo.chart) {
                vo.chart.group = nv || ''
            }
        },

        loading: function() {
            this.updateLoading()
        },

        loadingOptions: {
            deep: true,
            handler: function(nv, ov) {
                //模板物件字面值每次重繪皆為新物件, 內容相同時不重新顯示
                if (isPropUnchanged(nv, ov)) {
                    return
                }
                this.updateLoading()
            },
        },

        autoresize: function(nv, ov) {
            let vo = this

            //僅於啟用狀態或節流毫秒數改變時更換設定(v-domresize隨之重建偵測器); onResize於觸發時才讀取, 故模板物件字面值(每次重繪皆為新物件)或更換onResize皆不需更換
            let arNew = parseAutoresize(nv)
            let arOld = parseAutoresize(ov)
            if (arNew.enabled === arOld.enabled && arNew.throttle === arOld.throttle) {
                return
            }

            vo.resizeOpt = vo.genResizeOpt(nv)
        },

    },
    computed: {
    },
    methods: {

        /**
         * 初始化圖表, 建立echarts實例並綁定事件與讀取中動畫(內部使用)
         */
        init: function() {
            let vo = this

            //el
            let el = vo.$refs['$self']
            if (!el) {
                return
            }

            //init
            let chart = echarts.init(el, vo.theme || null, vo.initOptions || {})

            //chart, 掛this成非響應式屬性
            vo.chart = chart

            //group
            if (vo.group) {
                chart.group = vo.group
            }

            //setOption
            chart.setOption(vo.options || {}, vo.updateOptions || {})

            //事件轉發, 由$listeners綁定echarts事件, zr:前綴綁定zrender事件
            Object.keys(vo.$listeners || {}).forEach((key) => {

                //k, 剝除Vue事件修飾前綴(~為once, &為passive, !為capture)
                let k = key.replace(/^[~!&]+/, '')

                //fn
                let fn = vo.$listeners[key]

                //on
                if (k.indexOf('zr:') === 0) {
                    chart.getZr().on(k.slice(3), fn)
                }
                else {
                    chart.on(k, fn)
                }

            })

            //updateLoading
            vo.updateLoading()

        },

        /**
         * 銷毀並重建圖表, 供theme或initOptions變更時使用(內部使用)
         */
        reinit: function() {
            let vo = this

            //clear
            vo.clear()

            //init
            vo.init()

        },

        /**
         * 更新讀取中動畫(內部使用)
         */
        updateLoading: function() {
            let vo = this

            //check
            if (!vo.chart) {
                return
            }

            //showLoading, hideLoading
            if (vo.loading) {
                vo.chart.showLoading('default', vo.loadingOptions || {})
            }
            else {
                vo.chart.hideLoading()
            }

        },

        /**
         * 產生v-domresize之設定, autoresize停用時為false(不建立偵測器)(內部使用)
         *
         * 以w-component-vue之v-domresize(wsemi domDetect: ResizeObserver, 並處理元素消失與重建, 不支援時退回輪詢)監聽容器尺寸:
         * event為'resize'只聽容器尺寸, 視窗改變而容器不變時不重繪; sync於ResizeObserver回呼內(繪製前)發出使圖表於同一幀重繪; tolerancePixel為0使1px之變化亦發出;
         * getSize以與echarts相同之方式量測容器(clientWidth扣除padding), 故只改padding而外框不變者亦重繪; getBase為圖表目前尺寸,
         * 故尺寸一致時(含掛載時與由隱藏轉顯示時)不重繪而不中斷進場動畫, 且與回報次序無關, 同一幀內之尺寸變化(圖剛建立、父組件重繪、捲軸出現)不會遺漏
         */
        genResizeOpt: function(autoresize) {
            let vo = this
            let ar = parseAutoresize(autoresize)
            if (!ar.enabled) {
                return false
            }
            return {
                event: 'resize',
                sync: true,
                tolerancePixel: 0,
                throttle: ar.throttle,
                getSize: getContainerSize,
                getBase: vo.getChartSize,
            }
        },

        /**
         * 取得圖表目前尺寸, 供v-domresize比較, initOptions指定寬高(非'auto')之維度不隨容器變化故回傳null(不比較), 尚無圖表時回傳null(內部使用)
         */
        getChartSize: function() {
            let vo = this

            //chart
            let chart = vo.chart
            if (!chart) {
                return null
            }

            //fixedWidth, fixedHeight
            let io = vo.initOptions || {}
            let fixedWidth = io.width != null && io.width !== 'auto'
            let fixedHeight = io.height != null && io.height !== 'auto'

            return {
                width: fixedWidth ? null : chart.getWidth(),
                height: fixedHeight ? null : chart.getHeight(),
            }
        },

        /**
         * 容器尺寸與圖表不一致時(由v-domresize判定)重繪(內部使用)
         */
        onDomResize: function() {
            let vo = this

            //chart
            let chart = vo.chart
            if (!chart) {
                return
            }

            //resize
            chart.resize()

            //flush, echarts之resize不會同步繪製重排後之畫面(待下一幀), 於此立即繪製,
            //使onResize內可直接讀取新尺寸之畫面元素(例如getZr().storage.getDisplayList()), 並避免ResizeObserver回呼後之該幀顯示舊版面
            chart.getZr().flush()

            //onResize, 於觸發時才讀取, 故更換onResize不需重綁
            let { onResize } = parseAutoresize(vo.autoresize)
            if (onResize) {
                onResize()
            }

        },

        /**
         * 銷毀圖表(內部使用), 容器尺寸監聽由v-domresize隨組件解除
         */
        clear: function() {
            let vo = this

            //dispose
            if (vo.chart) {
                vo.chart.dispose()
                vo.chart = null
            }

        },

        /**
         * 取得內部echarts實例, 供外部呼叫echarts原生方法(setOption, resize, getDataURL, dispatchAction等)之用
         * 因echarts實例於組件掛載後才建立, 故初始化前呼叫會回傳null
         *
         * @returns {Object|null} 回傳echarts實例物件, 若尚未初始化則回傳null
         */
        getChart: function() {
            let vo = this
            return vo.chart || null
        },

    },
}
</script>

<style scoped>
.w-echarts-vue {
    display: block;
}
</style>