アニメーション効果の棒グラフ Yellowfin と amCharts の連携

## はじめに
[amCharts](https://www.amcharts.com/) と連携して、自動で軸と数値が入れ替わる棒グラフを作成したいと思います。時系列に応じて数値が変化する場合など、数値の変化の様子がとても分かりやすくなります。
![m7.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/5b827704-2b64-4471-9050-4375df631965.gif)

## 1. 事前準備
#### 1-1. 参照データ
マグニフィセント ７ (Alphabet, Amazon, Apple, Meta/Facebook, Microsoft, NVIDIA, Tesla) にバークシャー・ハサウェイ (代表：投資家ウォーレン・バフェット) を加えた、米国企業 8 社の過去 ３ 年間の株式時価総額の推移を見てみようと思います。
|year|company|price|
|---|---|---|
|2022|Apple|2,067 billion|
|・・・|・・・|・・・|
|2025|Tesla|776 billion|

データは[こちら](https://www.finhacker.cz/top-20-sp-500-companies-by-market-cap/)から入手しました。 2022 年から 2024 年分は 12 月末時点のデータ、2025 年分は 4 月 16 日の終値になります。

#### 1-2. ビューの作成まで
[以前の記事](https://qiita.com/ExtremeCarvFJ/items/ad453a6110cec093bdb9)  を参考に、『1-2. ビューの作成』 までの作業を実施します。

## 2. グラフの作成
準備が整ったら、グラフの作成に手順を進めます。新規でレポートを作成し、以下の手順に従ってグラフを作成します。
#### 2-1. [データ] ステップ
year、company、price をテーブルに配置します。
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/550a6d3d-189a-4fff-be82-8370b278a142.png)

#### 2-2. [グラフ] ステップ
[グラフ] ステップに進み、画面右側 [グラフの選択] から [JavaScriptグラフ] を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/9791d2c2-aa22-4a07-9fa9-650eaaac3c90.png)

##### JavaScriptタブ
雛形を全て削除し、以下のコードに置き換えます。コードの処理の内容は後ほど説明します。
```javascript:全容
generateChart = function(options) {
    var $chartDrawDiv = $(options.divSelector);
    var processedData2022 = processData2022(options.dataset.data);
    var processedData2023 = processData2023(options.dataset.data);
    var processedData2024 = processData2024(options.dataset.data);
    var processedData2025 = processData2025(options.dataset.data);
    doDrawing($chartDrawDiv, processedData2022, processedData2023, processedData2024, processedData2025);
},

processData2022 = function(dataset) {
    var ds2022={};
    for (i=0;i<dataset.year.length;i++){
        if(dataset.year[i].raw_data === '2022'){
            key = dataset.company[i].raw_data;
            ds2022[key]= dataset.price[i].raw_data;
        }
    }
    console.log(JSON.stringify(ds2022));
    return ds2022;
},

processData2023 = function(dataset) {
    var ds2023={};
    for (k=0;k<dataset.year.length;k++){
        if(dataset.year[k].raw_data === '2023'){
            key = dataset.company[k].raw_data;
            ds2023[key]= dataset.price[k].raw_data;
        }
    }
    console.log(JSON.stringify(ds2023));
    return ds2023;
},

processData2024 = function(dataset) {
    var ds2024={};
    for (l=0;l<dataset.year.length;l++){
        if(dataset.year[l].raw_data === '2024'){
            key = dataset.company[l].raw_data;
            ds2024[key]= dataset.price[l].raw_data;
        }
    }
    console.log(JSON.stringify(ds2024));
    return ds2024;
},

processData2025 = function(dataset) {
    var ds2025={};
    for (m=0;m<dataset.year.length;m++){
        if(trim(dataset.year[m].raw_data) === '2025'){
            key = dataset.company[m].raw_data;
            ds2025[key]= dataset.price[m].raw_data;
        }
    }
    console.log(JSON.stringify(ds2025));
    return ds2025;
},

doDrawing = function($chartDrawDiv, ds2022, ds2023, ds2024, ds2025) {
   require(['https://cdn.amcharts.com/lib/5/index.js','https://cdn.amcharts.com/lib/5/xy.js','https://cdn.amcharts.com/lib/5/themes/Animated.js'], function(){
       var $canvas = $('<div id="chartdiv"></div>');
       $chartDrawDiv.append($canvas);

       am5.ready(function(data) {

           // データセット受渡
            var allData = {
                "2022":ds2022,
                "2023":ds2023,
                "2024":ds2024,
                "2025":ds2025
            };
           
           // ルートエレメントの作成
           var root = am5.Root.new("chartdiv");
           
           root.numberFormatter.setAll({
             numberFormat: "#a",
           
             // M(millions 百万) B(billions 十億) 単位で丸め
             bigNumberPrefixes: [
               { number: 1e6, suffix: "M" },
               { number: 1e9, suffix: "B" }
             ],
           
             smallNumberPrefixes: []
           });
           
           var stepDuration = 2000;
           
           // テーマの設定
           root.setThemes([am5themes_Animated.new(root)]);
           
           // チャートの作成
           var chart = root.container.children.push(am5xy.XYChart.new(root, {
             panX: true,
             panY: true,
             wheelX: "none",
             wheelY: "none",
             paddingLeft: 0
           }));
           
           // ズームボタン非表示
           chart.zoomOutButton.set("forceHidden", true);
           
           // 軸の作成
           var yRenderer = am5xy.AxisRendererY.new(root, {
             minGridDistance: 20,
             inversed: true,
             minorGridEnabled: true
           });
           
           // グリッド非表示
           yRenderer.grid.template.set("visible", false);
           
           var yAxis = chart.yAxes.push(am5xy.CategoryAxis.new(root, {
             maxDeviation: 0,
             categoryField: "network",
             renderer: yRenderer
           }));
           
           var xAxis = chart.xAxes.push(am5xy.ValueAxis.new(root, {
             maxDeviation: 0,
             min: 0,
             strictMinMax: true,
             extraMax: 0.1,
             renderer: am5xy.AxisRendererX.new(root, {})
           }));
           
           xAxis.set("interpolationDuration", stepDuration / 10);
           xAxis.set("interpolationEasing", am5.ease.linear);
           
           // シリーズの追加
           var series = chart.series.push(am5xy.ColumnSeries.new(root, {
             xAxis: xAxis,
             yAxis: yAxis,
             valueXField: "value",
             categoryYField: "network"
           }));
           
           // 棒の角を丸く
           series.columns.template.setAll({ cornerRadiusBR: 5, cornerRadiusTR: 5 });
           
           // 棒の色を自動で選択
           series.columns.template.adapters.add("fill", function (fill, target) {
             return chart.get("colors").getIndex(series.columns.indexOf(target));
           });
           
           series.columns.template.adapters.add("stroke", function (stroke, target) {
             return chart.get("colors").getIndex(series.columns.indexOf(target));
           });
           
           // ラベルの設定
           series.bullets.push(function () {
             return am5.Bullet.new(root, {
               locationX: 1,
               sprite: am5.Label.new(root, {
                 text: "{valueXWorking.formatNumber('#.# a')}",
                 fill: root.interfaceColors.get("alternativeText"),
                 centerX: am5.p100,
                 centerY: am5.p50,
                 populateText: true
               })
             });
           });
           
           var label = chart.plotContainer.children.push(am5.Label.new(root, {
             text: "2022",
             fontSize: "8em",
             opacity: 0.2,
             x: am5.p100,
             y: am5.p100,
             centerY: am5.p100,
             centerX: am5.p100
           }));
           
           // カテゴリ毎にシリーズアイテムを取得
           function getSeriesItem(category) {
             for (var i = 0; i < series.dataItems.length; i++) {
               var dataItem = series.dataItems[i];
               if (dataItem.get("categoryY") == category) {
                 return dataItem;
               }
             }
           }
           
           // 軸の並び順
           function sortCategoryAxis() {
             // 数値の降順で並び替え
             series.dataItems.sort(function (x, y) {
               return y.get("valueX") - x.get("valueX");
             });
           
             // 軸アイテム毎に処理
             am5.array.each(yAxis.dataItems, function (dataItem) {
               // シリーズアイテムを取得
               var seriesDataItem = getSeriesItem(dataItem.get("category"));
           
               if (seriesDataItem) {
                 // シリーズデータアイテムのインデックスを取得
                 var index = series.dataItems.indexOf(seriesDataItem);
                 // アイテムのポジションを算出
                 var deltaPosition =
                   (index - dataItem.get("index", 0)) / series.dataItems.length;
                 // インデックスとシリーズデータアイテムのインデックスが同じになるように設定
                 if (dataItem.get("index") != index) {
                   dataItem.set("index", index);
                   // 各アイテムのポジションを設定（deltaPosition）
                   dataItem.set("deltaPosition", -deltaPosition);
                   // アニメーションのポジション（deltaPosition）を0に設定
                   dataItem.animate({
                     key: "deltaPosition",
                     to: 0,
                     duration: stepDuration / 2,
                     easing: am5.ease.out(am5.ease.cubic)
                   });
                 }
               }
             });
             // 軸のアイテムをインデックスに応じて設定
             yAxis.dataItems.sort(function (x, y) {
               return x.get("index") - y.get("index");
             });
           }
           
           var year = 2022;
           
           // 1.5秒毎にデータ更新
           var interval = setInterval(function () {
             year++;
           
             if (year > 2025) {
               clearInterval(interval);
               clearInterval(sortInterval);
             }
           
             updateData();
           }, stepDuration);
           
           var sortInterval = setInterval(function () {
             sortCategoryAxis();
           }, 100);
           
           function setInitialData() {
             var d = allData[year];
           
             for (var n in d) {
               series.data.push({ network: n, value: d[n] });
               yAxis.data.push({ network: n });
             }
           }
           
           function updateData() {
             var itemsWithNonZero = 0;
           
             if (allData[year]) {
               label.set("text", year.toString());
           
               am5.array.each(series.dataItems, function (dataItem) {
                 var category = dataItem.get("categoryY");
                 var value = allData[year][category];
           
                 if (value > 0) {
                   itemsWithNonZero++;
                 }
           
                 dataItem.animate({
                   key: "valueX",
                   to: value,
                   duration: stepDuration,
                   easing: am5.ease.linear
                 });
                 dataItem.animate({
                   key: "valueXWorking",
                   to: value,
                   duration: stepDuration,
                   easing: am5.ease.linear
                 });
               });
           
               yAxis.zoom(0, itemsWithNonZero / yAxis.dataItems.length);
             }
           }
           
           setInitialData();
           setTimeout(function () {
             year++;
             updateData();
           }, 50);
           
           // チャートのロード
           series.appear(1000);
           chart.appear(1000, 100);
           
        });
   });
};
```

## コード

##### generateChart
```JavaScript:generateChart
generateChart = function(options) {
    var $chartDrawDiv = $(options.divSelector);
    var processedData2022 = processData2022(options.dataset.data);
    var processedData2023 = processData2023(options.dataset.data);
    var processedData2024 = processData2024(options.dataset.data);
    var processedData2025 = processData2025(options.dataset.data);
    doDrawing($chartDrawDiv, processedData2022, processedData2023, processedData2024, processedData2025);
},
```

JavaScript グラフの処理において、最初に呼び出されるのが generateChart = function(options) です。
[データ] ステップでテーブルに追加した情報が options に格納されて、メソッドに受け渡されます。
processData2022、processData2023、processData2024、processData2025 でデータ処理を行うメソッドを呼び出し、doDrawingで描画するメソッドをそれぞれ呼び出しています。各メソッドで実行する具体的な処理は後述します。

##### processData2022

2022 年のデータとしては、以下のような連装配列を生成する必要があります。
```JSON:2022 年末時価総額
{
"Apple":2067000000000,
"Microsoft":1788000000000,
"Alphabet":1148000000000,
"Amazon":856940000000,
"Berkshire Hathaway":678720000000,
"Tesla":388970000000,
"NVIDIA":359500000000,
"Meta":315560000000
}
```

そのための処理が以下です。
```JavaScript:processData2022
processData2022 = function(dataset) {
    var ds2022={};
    for (i=0;i<dataset.year.length;i++){
        if(dataset.year[i].raw_data === '2022'){
            key = dataset.company[i].raw_data;
            ds2022[key]= dataset.price[i].raw_data;
        }
    }
    console.log(JSON.stringify(ds2022));
    return ds2022;
},
```

生成されるデータの中身を確認したい場合は、`console.log(JSON.stringify(ds2023))` で、データをコンソールに出力しておくと便利です。

同様に、processData2023、processData2024、processData2025 で、それぞれの年のデータを、各データセットに格納します。

##### CSS
CSS タブに下記を記述します。後ほど、定義した id を JavaScript から呼び出します。
```css:css
#chartdiv{
    height: 500px;
    width: 700px;
}
```

##### doDrawing
チャートの描画に関わる処理を記述します。
```JavaScript:doDrawing
doDrawing = function($chartDrawDiv, ds2022, ds2023, ds2024, ds2025) {
   require(['https://cdn.amcharts.com/lib/5/index.js','https://cdn.amcharts.com/lib/5/xy.js','https://cdn.amcharts.com/lib/5/themes/Animated.js'], function(){
       var $canvas = $('<div id="chartdiv"></div>');
       $chartDrawDiv.append($canvas);

       am5.ready(function(data) {

           // データセット受渡
            var allData = {
                "2022":ds2022,
                "2023":ds2023,
                "2024":ds2024,
                "2025":ds2025
            };
           
           // ルートエレメントの作成
           var root = am5.Root.new("chartdiv");
           
           root.numberFormatter.setAll({
             numberFormat: "#a",
           
             // M(millions 百万) B(billions 十億) 単位で丸め
             bigNumberPrefixes: [
               { number: 1e6, suffix: "M" },
               { number: 1e9, suffix: "B" }
             ],
           
             smallNumberPrefixes: []
           });
           
           var stepDuration = 2000;
           
           // テーマの設定
           root.setThemes([am5themes_Animated.new(root)]);
           
           // チャートの作成
           var chart = root.container.children.push(am5xy.XYChart.new(root, {
             panX: true,
             panY: true,
             wheelX: "none",
             wheelY: "none",
             paddingLeft: 0
           }));
           
           // ズームボタン非表示
           chart.zoomOutButton.set("forceHidden", true);
           
           // 軸の作成
           var yRenderer = am5xy.AxisRendererY.new(root, {
             minGridDistance: 20,
             inversed: true,
             minorGridEnabled: true
           });
           
           // グリッド非表示
           yRenderer.grid.template.set("visible", false);
           
           var yAxis = chart.yAxes.push(am5xy.CategoryAxis.new(root, {
             maxDeviation: 0,
             categoryField: "network",
             renderer: yRenderer
           }));
           
           var xAxis = chart.xAxes.push(am5xy.ValueAxis.new(root, {
             maxDeviation: 0,
             min: 0,
             strictMinMax: true,
             extraMax: 0.1,
             renderer: am5xy.AxisRendererX.new(root, {})
           }));
           
           xAxis.set("interpolationDuration", stepDuration / 10);
           xAxis.set("interpolationEasing", am5.ease.linear);
           
           // シリーズの追加
           var series = chart.series.push(am5xy.ColumnSeries.new(root, {
             xAxis: xAxis,
             yAxis: yAxis,
             valueXField: "value",
             categoryYField: "network"
           }));
           
           // 棒の角を丸く
           series.columns.template.setAll({ cornerRadiusBR: 5, cornerRadiusTR: 5 });
           
           // 棒の色を自動で選択
           series.columns.template.adapters.add("fill", function (fill, target) {
             return chart.get("colors").getIndex(series.columns.indexOf(target));
           });
           
           series.columns.template.adapters.add("stroke", function (stroke, target) {
             return chart.get("colors").getIndex(series.columns.indexOf(target));
           });
           
           // ラベルの設定
           series.bullets.push(function () {
             return am5.Bullet.new(root, {
               locationX: 1,
               sprite: am5.Label.new(root, {
                 text: "{valueXWorking.formatNumber('#.# a')}",
                 fill: root.interfaceColors.get("alternativeText"),
                 centerX: am5.p100,
                 centerY: am5.p50,
                 populateText: true
               })
             });
           });
           
           var label = chart.plotContainer.children.push(am5.Label.new(root, {
             text: "2022",
             fontSize: "8em",
             opacity: 0.2,
             x: am5.p100,
             y: am5.p100,
             centerY: am5.p100,
             centerX: am5.p100
           }));
           
           // カテゴリ毎にシリーズアイテムを取得
           function getSeriesItem(category) {
             for (var i = 0; i < series.dataItems.length; i++) {
               var dataItem = series.dataItems[i];
               if (dataItem.get("categoryY") == category) {
                 return dataItem;
               }
             }
           }
           
           // 軸の並び順
           function sortCategoryAxis() {
             // 数値の降順で並び替え
             series.dataItems.sort(function (x, y) {
               return y.get("valueX") - x.get("valueX");
             });
           
             // 軸アイテム毎に処理
             am5.array.each(yAxis.dataItems, function (dataItem) {
               // シリーズアイテムを取得
               var seriesDataItem = getSeriesItem(dataItem.get("category"));
           
               if (seriesDataItem) {
                 // シリーズデータアイテムのインデックスを取得
                 var index = series.dataItems.indexOf(seriesDataItem);
                 // アイテムのポジションを算出
                 var deltaPosition =
                   (index - dataItem.get("index", 0)) / series.dataItems.length;
                 // インデックスとシリーズデータアイテムのインデックスが同じになるように設定
                 if (dataItem.get("index") != index) {
                   dataItem.set("index", index);
                   // 各アイテムのポジションを設定（deltaPosition）
                   dataItem.set("deltaPosition", -deltaPosition);
                   // アニメーションのポジション（deltaPosition）を0に設定
                   dataItem.animate({
                     key: "deltaPosition",
                     to: 0,
                     duration: stepDuration / 2,
                     easing: am5.ease.out(am5.ease.cubic)
                   });
                 }
               }
             });
             // 軸のアイテムをインデックスに応じて設定
             yAxis.dataItems.sort(function (x, y) {
               return x.get("index") - y.get("index");
             });
           }
           
           var year = 2022;
           
           // 1.5秒毎にデータ更新
           var interval = setInterval(function () {
             year++;
           
             if (year > 2025) {
               clearInterval(interval);
               clearInterval(sortInterval);
             }
           
             updateData();
           }, stepDuration);
           
           var sortInterval = setInterval(function () {
             sortCategoryAxis();
           }, 100);
           
           function setInitialData() {
             var d = allData[year];
           
             for (var n in d) {
               series.data.push({ network: n, value: d[n] });
               yAxis.data.push({ network: n });
             }
           }
           
           function updateData() {
             var itemsWithNonZero = 0;
           
             if (allData[year]) {
               label.set("text", year.toString());
           
               am5.array.each(series.dataItems, function (dataItem) {
                 var category = dataItem.get("categoryY");
                 var value = allData[year][category];
           
                 if (value > 0) {
                   itemsWithNonZero++;
                 }
           
                 dataItem.animate({
                   key: "valueX",
                   to: value,
                   duration: stepDuration,
                   easing: am5.ease.linear
                 });
                 dataItem.animate({
                   key: "valueXWorking",
                   to: value,
                   duration: stepDuration,
                   easing: am5.ease.linear
                 });
               });
           
               yAxis.zoom(0, itemsWithNonZero / yAxis.dataItems.length);
             }
           }
           
           setInitialData();
           setTimeout(function () {
             year++;
             updateData();
           }, 50);
           
           // チャートのロード
           series.appear(1000);
           chart.appear(1000, 100);
           
        });
   });
};
```

* `require[]` の中で、JavaScriptライブラリの場所を指定します。上記の例では CDN のアドレスを指定していますが、JS ライブラリを[ダウンロード](https://www.amcharts.com/download/)して利用することも可能です
* `var allData = {}` の中で、各年のデータセットを配置しています
* `var root = am5.Root.new("chartdiv")` で、CSS で定義した id を指定します
* `root.setThemes` で、動きのある Animated テーマを設定しています
* それ以降の処理の中では、チャートの表示を設定し、アニメーションの動きを定義しています。各箇所での設定は、コードの中のコメントアウト行を参考にしてください
* 設定の詳細に関しては、[XY チャート](https://www.amcharts.com/docs/v5/charts/xy-chart/)のドキュメントをご参照ください。[シリーズ](https://www.amcharts.com/docs/v5/charts/xy-chart/series/)や[軸](https://www.amcharts.com/docs/v5/charts/xy-chart/axes/)に関するドキュメントもそろっています

## 3. 動作確認
![m7.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/5cfb1c45-8c6a-4666-a9cd-392821c499ce.gif)

チャートを表示してみると、2023 年末から 2025 年 4 月 16 日終値までの 8 社の株価の動きがアニメーションで確認できます。2024 年に各社の時価総額が大きく上振れするものの、2025 年 4 月に入ると、マグニフィセント 7 の時価総額が下落している様子が把握できます。トランプ関税の影響で、特に iPhone の生産の中心が中国である Apple 社の減少幅は大きいですね。NVIDIA も、近年は右肩上がりに時価総額を伸ばしてきているものの、トランプ関税の影響で、株価を低減させている様子がアニメーションで確認できます。
一方で、近年は日本の商社株を買い増ししていることでも知られるバークシャー・ハサウェイは、堅調に時価総額を伸ばしています。さすがウォーレン・バフェット、しごできな爺さんですね。

## 最後に
手動で切り替えながら数値の動きを見るのも良いのですが、アニメーションでしか表現できないものもありますよね。何より、動きのあるチャートは見栄えがします。
そんな時に利用をご検討ください。

では皆様、良いデータ分析を！