搜尋此網誌

2023年11月30日 星期四

JavaScript Proxy 客製 Amchart 5 的 ForceDirected (二)

緣起:


    接續這篇,這邊要介紹的是,怎麼在 ForceDirected 的 LinkedHierarchyNode 裡面放圖片,並讓圖片的大小可以跟著圓圈的大小來變動,這也是我碰到的最大難題,因為我沒在 api 文件裡找到任何跟這個有關的功能。

    官方雖然有教如何在圓圈裡面放圖片,但放完圖片後,圖片的 size 不會隨著圓圈的大小來變動,所以只能自己再另尋方法。

    我把自己隨意畫的圓圈 png 圖傳到我作為 blogger CDN 的 github 專案,為了讓圖片更完美的切合 ForceDirected 的圓圈,所以圖片的長跟寬都是跟圓圈的直徑一樣。


 

在 LinkedHierarchyNode 放圖片:


    如果按照官方寫的,把圖片加入 Node 裡,我的程式會是這樣

myAm5ForceDirected.nodes.template.setup = function (target) {
    target.events.on("dataitemchanged", function (ev) {
        var icon = target.children.push(am5.Picture.new(root, {
            width: 70,
            height: 70,
            centerX: am5.percent(50),
            centerY: am5.percent(50),
            src: ev.target.dataItem.dataContext.image
        }));
    });
}

    如果我之後想要再接著設定圖片大小符合圓的大小時,需要取得 Circle,但在 dataitemchanged 發生的當下,LinkedHierarchyNode 的 children 裡面還沒有加入 Circle,所以也沒辧法取得 Circle 的 radius。

    後來發現,比較優的解法是設定在 root 的 frameended 事件中,因為事件發生的時候,元件都有加上去了,所以整個 index.js 程式會寫成

document.addEventListener("DOMContentLoaded", function () {
    let myAm5Root = am5.Root.new("myAmchartDiv"); //傳入 div 的 id 

    myAm5Root.setThemes([
        am5themes_Animated.new(myAm5Root) //設定 root 的動畫效果,預設即可
    ]);

    let myAm5Container = myAm5Root.container.children.push(//root 裡放入一個 container
        am5.Container.new(myAm5Root, {
            width: am5.percent(100),
            height: am5.percent(100),
            layout: myAm5Root.verticalLayout
        })
    );

    let myAm5ForceDirected = myAm5Container.children.push( //container 裡面放入 ForceDirected 圖表
        am5hierarchy.ForceDirected.new(myAm5Root, {
            downDepth: 1,
            initialDepth: 3, //初始展開層數
            topDepth: 1,
            valueField: "value",
            categoryField: "name",
            childDataField: "children"
        })
    );

    //圖表資料 (物件陣列)
    let myObjData = [{
        name: "Root",
        value: 0,
        children: [{
            name: "A0",
            value: 100,
            image:"https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle1.png",
            children: [{
                name: "A0A1",
                image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle2.png",
                value: 80,
                children: [{
                    name: "A0A0A2",
                    image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle3.png",
                    value: 71
                }, {
                    name: "A0A0C2",
                    image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle4.png",
                    value: 48
                }]
            }, {
                name: "A0B1",
                image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle5.png",
                value: 27
            }, {
                name: "A0C1",
                image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle6.png",
                value: 70,
                children: [{
                    name: "A0C2A2",
                    image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle7.png",
                    value: 40
                }, {
                    name: "A0C2B2",
                    image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle8.png",
                    value: 40,
                    children: [{
                        name: "A0C2B1A3",
                        image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle9.png",
                        value: 54
                    }]
                }]
            }, {
                name: "A0D1",
                image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle10.png",
                value: 89
            }]
        }]
    }];

    myAm5ForceDirected.nodes.template.set("draggable", false); //設定球球不能拖拉
    myAm5ForceDirected.nodes.template.set("toggleKey", "none");//設定球球不能點擊展開或收合
    myAm5ForceDirected.outerCircles.template.set("forceHidden", true); //把外層的圓圈給藏起來
    myAm5ForceDirected.data.setAll(myObjData); //設定 ForceDirected 的資料
    myAm5ForceDirected.set("selectedDataItem", myAm5ForceDirected.dataItems[0]);//採用第一個 Object

    myAm5Root.events.on('frameended', frameendedfunction); //root 的 frameended
    function frameendedfunction(ev) {
        //由於圖片只需要設定一次,所以執行時要順便解除 frameended 事件
        myAm5Root.events.off('frameended', frameendedfunction);
        myAm5ForceDirected.nodes.each(function (node, index) {
            let am5Picture = node.children.push(am5.Picture.new(myAm5Root, { //設定 node 的圖片
                centerX: am5.percent(50),
                centerY: am5.percent(50),
                width: 70, //長
                height: 70, //寬
                src: node.dataItem.dataContext.image //可以透過 dataItem.dataContext 來取得對應的 Object 資料
            }));
        })
    }

});

28~76 行 : 原本的 Object data,每個 Object 都再加入 image 的 key,value 對應的是圖片網址

84 行 : 監聽 root 的 frameended 事件,由於之後還需要解除,所以 function 不能寫成匿名的,這邊是新增一個叫 frameendedfunction 來註冊。

88 行 : 執行的當下,用 nodes 的 each 方法來逐一為每個 node 加入 image,把圖片放在中心,長跟寬都先固定

    結果會是像這樣

See the Pen ForceDirected-4 by 鳥旭 (@lbdjszpl-the-lessful) on CodePen.


    由於圖片大小是設固定的,所以擺在大小不一的圓上,看起來很怪,所以接下來要依每個圓的大小來設定圖片。

    從 node 的 children 裡把 Circle 給取出來後,可以透過 get 來取得 radius 屬性


    所以 frameendedfunction 就會改寫成這樣

function frameendedfunction(ev) {
    //由於圖片只需要設定一次,所以執行時要順便解除 frameended 事件
    myAm5Root.events.off('frameended', frameendedfunction);
    myAm5ForceDirected.nodes.each(function (node, index) {
        let am5Picture = node.children.push(am5.Picture.new(myAm5Root, { //設定 node 的圖片
            centerX: am5.percent(50),
            centerY: am5.percent(50),
            width: 70, //長
            height: 70, //寬
            src: node.dataItem.dataContext.image //可以透過 dataItem.dataContext 來取得對應的 Object 資料
        }));

        let am5Circle = null;
        node.children.each(function (nodeChildren, nodeChildrenIndex) {
            if (nodeChildren.className === 'Circle') {
                //這邊會有兩個 Circle,外層跟內層,外層的 forceHidden 有被設定成 true,所以要取 forceHidden 為 undefined 的 Circle
                if (nodeChildren.get('forceHidden') === undefined) am5Circle = nodeChildren;
            }
        });

        am5Picture.setAll({ //依圓的大小來設定圖片的高跟寬
            width: am5Circle.get('radius') * 2,
            height: am5Circle.get('radius') * 2
        });
    })
}

    這邊要注意的是第 17 行,LinkedHierarchyNode 的 children 裡會有兩個 Circle,一個是外層的,就是邊框,我們有把它給隱藏起來 (設定 forceHidden 屬性),另一個 Circle 才是我們要的,所以這裡用 nodeChildren.get('forceHidden') === undefined 來判斷。

    結果會是這樣.....

See the Pen ForceDirected-4 by 鳥旭 (@lbdjszpl-the-lessful) on CodePen.


    看起來怪怪的,我在猜,大概又是執行的當下,Circle 的 radius 還沒被設定好,所以才會這樣。如果用 setTimeout 把設定長寬的動作延後個 300 毫秒,就可以得到想要的結果了

function frameendedfunction(ev) {
    //由於圖片只需要設定一次,所以執行時要順便解除 frameended 事件
    myAm5Root.events.off('frameended', frameendedfunction);
    myAm5ForceDirected.nodes.each(function (node, index) {
        let am5Picture = node.children.push(am5.Picture.new(myAm5Root, { //設定 node 的圖片
            centerX: am5.percent(50),
            centerY: am5.percent(50),
            width: 70, //長
            height: 70, //寬
            src: node.dataItem.dataContext.image //可以透過 dataItem.dataContext 來取得對應的 Object 資料
        }));

        let am5Circle = null;
        node.children.each(function (nodeChildren, nodeChildrenIndex) {
            if (nodeChildren.className === 'Circle') {
                //這邊會有兩個 Circle,外層跟內層,外層的 forceHidden 有被設定成 true,所以要取 forceHidden 為 undefined 的 Circle
                if (nodeChildren.get('forceHidden') === undefined) am5Circle = nodeChildren;
            }
        });

        setTimeout(() => {
            am5Picture.setAll({ //依圓的大小來設定圖片的高跟寬
                width: am5Circle.get('radius') * 2,
                height: am5Circle.get('radius') * 2
            });
        }, 300);//延後 300 毫秒
    })
}

    結果

See the Pen ForceDirected-5 by 鳥旭 (@lbdjszpl-the-lessful) on CodePen.


    這樣就能在載入圖表後,讓圖片的大小盡量貼齊所屬 Circle 的大小,但是之後 Circle 如果有變化,圖片是不會再變動的。


使用 Proxy 來監控 Circle 的變化:


    我自己在測試時,在列印 Circle 物件有發現到,它有一個叫 _settings 的屬性,然後 _settings 裡又有一個型態為 Number 的 radius 屬性


    我有試著用全域變數來把某個 Circle 的 _settings 給記下來,然後在調整完視窗、圓的大小有變化後,再把那變數給印出來,發現,每次調整完之後,它的 radius 確實都會有變化。這代表 ForceDirected 的程式在動態調整圓圈時,會去動到那個 _settings 的 radius,所以,只要可以監聽到那個 _settings 在針對 radius 上的操作,當 radius 有變化時,我就把 Picture 的長跟寬設定成 radius X 2 ,這麼一來,那些圖片也就能跟著圓的大小來動態調整了。

    我在 Proxy 的使用上,是直接參考別人寫好的範例,然後再改成我要的版本,最後寫出來的創造 Proxy 物件的 function 長這樣。我把它給宣告在 index.js 的 document.addEventListener("DOMContentLoaded") 外面。

function MyProxy(targetObject, callback) {
    const handler = {
        get(target, property, receiver) {
            try {
                return new Proxy(target[property], handler);
            } catch (err) {
                return Reflect.get(target, property, receiver);
            }
        },
        set(target, property, value) {
            if (property == 'radius') {
                callback(value)
            }
            target[property] = value;
            return true;
        }
    };
    return new Proxy(targetObject, handler);
}

3~9 行 : get 方法,我一開始看不懂它為啥會寫得那麼麻煩,後來才發覺,它這個很像遞迴的寫法,其實是為了處理複雜物件,如果確定 targetObject 就只是簡單物件的話,那其實直接寫成第 7 行那樣就行。get 的部份,我們就不多做什麼處理,就讓它是正常的取得屬性。

第 11 行 : 在 set 部份,如果它變動的是 radius 這個屬性,那我們就再執行傳入的 callback function,並把 value 給傳入。

    接著是改寫 frameendedfunction 裡的設定 Picture 大小部份,不要用 setTimeout 了

function frameendedfunction(ev) {
    //由於圖片只需要設定一次,所以執行時要順便解除 frameended 事件
    myAm5Root.events.off('frameended', frameendedfunction);
    myAm5ForceDirected.nodes.each(function (node, index) {
        let am5Picture = node.children.push(am5.Picture.new(myAm5Root, { //設定 node 的圖片
            centerX: am5.percent(50),
            centerY: am5.percent(50),
            width: 70, //長
            height: 70, //寬
            src: node.dataItem.dataContext.image //可以透過 dataItem.dataContext 來取得對應的 Object 資料
        }));

        let am5Circle = null;
        node.children.each(function (nodeChildren, nodeChildrenIndex) {
            if (nodeChildren.className === 'Circle') {
                //這邊會有兩個 Circle,外層跟內層,外層的 forceHidden 有被設定成 true,所以要取 forceHidden 為 undefined 的 Circle
                if (nodeChildren.get('forceHidden') === undefined) am5Circle = nodeChildren;
            }
        });

        //把 Circle 的 _settings 給掉包成 Proxy 版本的 _setting
        am5Circle._settings = MyProxy(am5Circle._settings, function (iRadius) {
            am5Picture.setAll({ //依傳入的半徑來設定圖片的長跟寬
                width: iRadius * 2,
                height: iRadius * 2
            });
        })
    })
}

    最後,完整的 index.js 程式碼會是這樣

function MyProxy(targetObject, callback) {
    const handler = {
        get(target, property, receiver) {
            try {
                return new Proxy(target[property], handler);
            } catch (err) {
                return Reflect.get(target, property, receiver);
            }
        },
        set(target, property, value) {
            if (property == 'radius') {
                callback(value)
            }
            target[property] = value;
            return true;
        }
    };
    return new Proxy(targetObject, handler);
}

document.addEventListener("DOMContentLoaded", function () {
    let myAm5Root = am5.Root.new("myAmchartDiv"); //傳入 div 的 id 

    myAm5Root.setThemes([
        am5themes_Animated.new(myAm5Root) //設定 root 的動畫效果,預設即可
    ]);

    let myAm5Container = myAm5Root.container.children.push(//root 裡放入一個 container
        am5.Container.new(myAm5Root, {
            width: am5.percent(100),
            height: am5.percent(100),
            layout: myAm5Root.verticalLayout
        })
    );

    let myAm5ForceDirected = myAm5Container.children.push( //container 裡面放入 ForceDirected 圖表
        am5hierarchy.ForceDirected.new(myAm5Root, {
            downDepth: 1,
            initialDepth: 3, //初始展開層數
            topDepth: 1,
            valueField: "value",
            categoryField: "name",
            childDataField: "children"
        })
    );

    //圖表資料 (物件陣列)
    let myObjData = [{
        name: "Root",
        value: 0,
        children: [{
            name: "A0",
            value: 100,
            image:"https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle1.png",
            children: [{
                name: "A0A1",
                image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle2.png",
                value: 80,
                children: [{
                    name: "A0A0A2",
                    image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle3.png",
                    value: 71
                }, {
                    name: "A0A0C2",
                    image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle4.png",
                    value: 48
                }]
            }, {
                name: "A0B1",
                image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle5.png",
                value: 27
            }, {
                name: "A0C1",
                image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle6.png",
                value: 70,
                children: [{
                    name: "A0C2A2",
                    image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle7.png",
                    value: 40
                }, {
                    name: "A0C2B2",
                    image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle8.png",
                    value: 40,
                    children: [{
                        name: "A0C2B1A3",
                        image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle9.png",
                        value: 54
                    }]
                }]
            }, {
                name: "A0D1",
                image: "https://cdn.jsdelivr.net/gh/birdshiu/static-blogger-resource/image/amchart-forcedirected/ForceDirected-Circle10.png",
                value: 89
            }]
        }]
    }];

    myAm5ForceDirected.nodes.template.set("draggable", false); //設定球球不能拖拉
    myAm5ForceDirected.nodes.template.set("toggleKey", "none");//設定球球不能點擊展開或收合
    myAm5ForceDirected.outerCircles.template.set("forceHidden", true); //把外層的圓圈給藏起來
    myAm5ForceDirected.data.setAll(myObjData); //設定 ForceDirected 的資料
    myAm5ForceDirected.set("selectedDataItem", myAm5ForceDirected.dataItems[0]);//採用第一個 Object

    myAm5Root.events.on('frameended', frameendedfunction); //root 的 frameended
    function frameendedfunction(ev) {
        //由於圖片只需要設定一次,所以執行時要順便解除 frameended 事件
        myAm5Root.events.off('frameended', frameendedfunction);
        myAm5ForceDirected.nodes.each(function (node, index) {
            let am5Picture = node.children.push(am5.Picture.new(myAm5Root, { //設定 node 的圖片
                centerX: am5.percent(50),
                centerY: am5.percent(50),
                width: 70, //長
                height: 70, //寬
                src: node.dataItem.dataContext.image //可以透過 dataItem.dataContext 來取得對應的 Object 資料
            }));

            let am5Circle = null;
            node.children.each(function (nodeChildren, nodeChildrenIndex) {
                if (nodeChildren.className === 'Circle') {
                    //這邊會有兩個 Circle,外層跟內層,外層的 forceHidden 有被設定成 true,所以要取 forceHidden 為 undefined 的 Circle
                    if (nodeChildren.get('forceHidden') === undefined) am5Circle = nodeChildren;
                }
            });

            //把 Circle 的 _settings 給掉包成 Proxy 版本的 _setting
            am5Circle._settings = MyProxy(am5Circle._settings, function (iRadius) {
                am5Picture.setAll({ //依傳入的半徑來設定圖片的長跟寬
                    width: iRadius * 2,
                    height: iRadius * 2
                });
            })
        })
    }
});

    這樣,就能讓 Picture 隨著 Circle 的大小而變化了

See the Pen ForceDirected-6 by 鳥旭 (@lbdjszpl-the-lessful) on CodePen.



沒有留言:

張貼留言