トラックチャートで営業成績を可視化 ‐ Yellowfin と amCharts の連携

## はじめに
[amCharts](https://www.amcharts.com/) と連携して、動きが面白いチャートを作成します。
amCharts には、時系列データを可視化する目的の [Timeline チャート](https://www.amcharts.com/demos-v4/timeline-v4/)が備わっています。Timeline チャートをうまくカスタマイズして、トラックチャートに仕上げます。

## 1. 事前準備
#### 1-1. 参照データ
営業担当が 6 名いる前提で、架空のデータを作成しました。file 列のアドレスは凡例のアイコンに使われる画像ファイルです。track 列がトラックの番号、value 列が営業成績です。
|name|file|track|value|
|---|---|---|---|
|山田よし子|https://\<your_url\>/1.svg|1|760|
|ハーゼンバーグ太郎|https://\<your_url\>/2.svg|2|390|
|杉本みのり|https://\<your_url\>/3.svg|3|520|
|谷崎のび太|https://\<your_url\>/4.svg|4|690|
|北村ゆり|https://\<your_url\>/5.svg|5|480|
|工藤ぽっこり|https://\<your_url\>/6.svg|6|440|

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

## 2. グラフの作成
準備が整ったら、グラフの作成に手順を進めます。新規でレポートを作成し、以下の手順に従ってグラフを作成します。
#### 2-1. [データ] ステップ
name、track、value、file をテーブルに配置します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/f68695db-1dd2-417e-b9a2-9ef4154ef81d.png)

#### 2-2. [グラフ] ステップ
[グラフ] ステップに進み、画面右側 [グラフの選択] から [JavaScriptグラフ] を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/56251948-0fc7-46de-a83f-0042850416ce.png)

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

processData = function(dataset) {
    var ds = [];
    for (i=0;i<dataset.name.length;i++){
        ds.push({
            name:dataset.name[i].raw_data,
            file:dataset.file[i].raw_data,
            track:dataset.track[i].raw_data,
            value:dataset.value[i].raw_data
        });
    }
    console.log(JSON.stringify(ds));
    return ds;
},

doDrawing = function($chartDrawDiv, ds) {
    require(['https://cdn.amcharts.com/lib/5/index.js',
            'https://cdn.amcharts.com/lib/5/xy.js',
            'https://cdn.amcharts.com/lib/5/timeline.js',
            'https://cdn.amcharts.com/lib/5/themes/Animated.js'], function(){
        var $canvas = $('<div id="chartdiv"></div>');
        $chartDrawDiv.append($canvas);
        
        am5.ready(function() {
        
        var root = am5.Root.new("chartdiv");
        
        // テーマの設定
        root.setThemes([
          am5themes_Animated.new(root)
        ]);
        
        // TimelineのCurveChartを利用
        // マウストラックに対する動作を設定
        var chart = root.container.children.push(am5timeline.CurveChart.new(root, {
          wheelY: "zoomX"
        }));
        
        //Y軸(ランナーの名前にかかわる軸)
        var yRenderer = am5timeline.AxisRendererCurveY.new(root, {
          axisLength: 250,
          minGridDistance: 10,
          axisLocation: 0
        })
        
        //トラック上の名前
        yRenderer.labels.template.setAll({
          fontSize: 11,
          layer: 30,
          fill: am5.color(0xffffff),
          fontWeight: "bold",
          centerX: am5.p100
        });
        
        //トラックの線
        yRenderer.grid.template.setAll({
          stroke: am5.color(0xffffff),
          strokeOpacity: 1,
          layer: 30
        })
        
        //トラックの色
        yRenderer.axisFills.template.setAll({
          fill: am5.color(0xb84f49),
          forceHidden: false,
          fillOpacity: 1,
          visible: true
        });
        
        // X軸(トラックの距離を示す軸)
        var xRenderer = am5timeline.AxisRendererCurveX.new(root, {
          yRenderer: yRenderer,
          rotateLabels: true,
          points: getPoints(),
          minGridDistance: 200,
          stroke: am5.color(0x00000),
          strokeOpacity:0.1,
          strokeWidth:20
        });
        
        //100メートルごとの線
        xRenderer.grid.template.setAll({
          stroke: am5.color(0xffffff),
          strokeOpacity: 1,
          layer: 30
        })
        
        //メートルのラベル
        xRenderer.labels.template.setAll({
          maxPosition: 0.98,
          fontSize: 13,
          layer: 30,
          fill: am5.color(0xffffff),
          fontWeight: "bold",
          centerY: am5.p100
        });
        
        //name分のトラックを表示
        var yAxis = chart.yAxes.push(am5xy.CategoryAxis.new(root, {
          categoryField: "name",
          renderer: yRenderer,
          fillRule: function (dataItem) {
            const axisFill = dataItem.get("axisFill");
            if (axisFill) {
              axisFill.set("visible", true);
            }
          }
        }));
        
        //トラックの距離を設定
        var xAxis = chart.xAxes.push(am5xy.ValueAxis.new(root, {
          renderer: xRenderer,
          min: 0,
          max: 1000,
          strictMinMax: true
        }));
        
        //Series(データポイント)の設定
        var series = chart.series.push(am5timeline.CurveColumnSeries.new(root, {
          maskBullets: false,
          xAxis: xAxis,
          yAxis: yAxis,
          valueXField: "value",
          categoryYField: "name",
          tooltip: am5.Tooltip.new(root, {
            labelText: "{category}: {value}"
          })
        }))
        
        series.columns.template.setAll({
          fill: am5.color(0xffffff),
          strokeOpacity: 0,
          fillOpacity: 0.5,
          layer: 30,
          interactive: true
        });
        
        //マウスを凡例にホバーした時のトラック上の動作
        series.columns.template.states.create("hover", {
          fillOpacity: 1
        });
        
        //Bulletの描画
        series.bullets.push(function (root, series, dataItem) {
          return am5.Bullet.new(root, {
            sprite: am5.Circle.new(root, {
              radius: 5,
              fillOpacity: 1,
              fill: am5.color(0xffffff)
            })
          });
        });
        
        //データをセット
        var data = ds;
        yAxis.data.setAll(data);
        series.data.setAll(data);
        series.appear(3000, 100);
        
        // 凡例の設定
        var legend = chart.curveContainer.children.push(am5.Legend.new(root, {
          centerX: am5.p50,
          centerY: am5.p50,
          useDefaultMarker: true,
          nameField: "categoryY",
          maxWidth: 350  
        }));
        
        //凡例の表示と動作の設定
        legend.valueLabels.template.setAll({
          forceHidden: true
        });
        
        legend.markers.template.setup = function (marker) {
          marker.events.on("dataitemchanged", function () {
            var dataItem = marker.dataItem
            var seriesDataItem = dataItem.dataContext;
            var picture = marker.children.push(am5.Picture.new(root, {
              width: 30,
              height: 30,
              src: seriesDataItem.dataContext.file
            }));
        
            marker.set("width", 30);
            marker.set("height", 30);
        
            dataItem.on("markerRectangle", function (rectangle) {
              rectangle.set("forceHidden", true);
            })
          });
        }
        
        //マウスポインターをトラック上にホバーした時とホバーを外した時の動作
        legend.itemContainers.template.events.on("pointerover", function (e) {
          var seriesDataItem = e.target.dataItem.dataContext;
          if (seriesDataItem) {
            var graphics = seriesDataItem.get("graphics");
            if (graphics) {
              graphics.hover();
            }
          }
        });
        
        legend.itemContainers.template.events.on("pointerout", function (e) {
          var seriesDataItem = e.target.dataItem.dataContext;
          if (seriesDataItem) {
            var graphics = seriesDataItem.get("graphics");
            if (graphics) {
              graphics.unhover();
            }
          }
        });
        
        //実際に設定して有効化
        legend.data.setAll(series.dataItems);
        
        //Timelineチャートがトラックを形成するように計算
        function getPoints() {
          var points = [];
          var radius = 300;
          points.push({ x: 0, y: -300 });
          points.push({ x: 300, y: -300 });
        
          for (var k = 1; k < 50; k++) {
            var angle = -180 - k / 50 * 180;
            points.push({ y: radius * am5.math.cos(angle), x: 300 + radius * am5.math.sin(angle) });
          }
        
          points.push({ x: -300, y: 300 });
        
          for (var k2 = 1; k2 < 50; k2++) {
            var angle2 = -k2 / 50 * 180;
            points.push({ y: radius * am5.math.cos(angle2), x: -300 + radius * am5.math.sin(angle2) });
          }
          points.push({ x: 0, y: -300 });
          return points;
        }
        
    });

})};
```

## コード

##### generateChart
```JavaScript:generateChart
generateChart = function(options) {
   var $chartDrawDiv = $(options.divSelector);
   var processedData = processData(options.dataset.data);
   doDrawing($chartDrawDiv, processedData);
},
```

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

##### processData
データとしては、以下のような連装配列を生成する必要があります
```JSON:ds
[
   {
      "name":"山田よし子",
      "file":"https://<your_url>/1.svg",
      "track":1,
      "value":760
   },
   {
      "name":"ハーゼンバーグ太郎",
      "file":"https://<your_url>/2.svg",
      "track":2,
      "value":390}
]
```

そのための処理が以下です
```JavaScript:processData
processData = function(dataset) {
    var ds = [];
    for (i=0;i<dataset.name.length;i++){
        ds.push({
            name:dataset.name[i].raw_data,
            file:dataset.file[i].raw_data,
            track:dataset.track[i].raw_data,
            value:dataset.value[i].raw_data
        });
    }
    console.log(JSON.stringify(ds));
    return ds;
},
```

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

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

##### doDrawing
チャートの描画に関わる処理を記述します。
```JavaScript:doDrawing
doDrawing = function($chartDrawDiv, ds) {
    require(['https://cdn.amcharts.com/lib/5/index.js',
            'https://cdn.amcharts.com/lib/5/xy.js',
            'https://cdn.amcharts.com/lib/5/timeline.js',
            'https://cdn.amcharts.com/lib/5/themes/Animated.js'], function(){
        var $canvas = $('<div id="chartdiv"></div>');
        $chartDrawDiv.append($canvas);
        
        am5.ready(function() {
        
        var root = am5.Root.new("chartdiv");
        
        // テーマの設定
        root.setThemes([
          am5themes_Animated.new(root)
        ]);
        
        // TimelineのCurveChartを利用
        // マウストラックに対する動作を設定
        var chart = root.container.children.push(am5timeline.CurveChart.new(root, {
          wheelY: "zoomX"
        }));
        
        //Y軸(ランナーの名前にかかわる軸)
        var yRenderer = am5timeline.AxisRendererCurveY.new(root, {
          axisLength: 250,
          minGridDistance: 10,
          axisLocation: 0
        })
        
        //トラック上の名前
        yRenderer.labels.template.setAll({
          fontSize: 11,
          layer: 30,
          fill: am5.color(0xffffff),
          fontWeight: "bold",
          centerX: am5.p100
        });
        
        //トラックの線
        yRenderer.grid.template.setAll({
          stroke: am5.color(0xffffff),
          strokeOpacity: 1,
          layer: 30
        })
        
        //トラックの色
        yRenderer.axisFills.template.setAll({
          fill: am5.color(0xb84f49),
          forceHidden: false,
          fillOpacity: 1,
          visible: true
        });
        
        // X軸(トラックの距離を示す軸)
        var xRenderer = am5timeline.AxisRendererCurveX.new(root, {
          yRenderer: yRenderer,
          rotateLabels: true,
          points: getPoints(),
          minGridDistance: 200,
          stroke: am5.color(0x00000),
          strokeOpacity:0.1,
          strokeWidth:20
        });
        
        //100メートルごとの線
        xRenderer.grid.template.setAll({
          stroke: am5.color(0xffffff),
          strokeOpacity: 1,
          layer: 30
        })
        
        //メートルのラベル
        xRenderer.labels.template.setAll({
          maxPosition: 0.98,
          fontSize: 13,
          layer: 30,
          fill: am5.color(0xffffff),
          fontWeight: "bold",
          centerY: am5.p100
        });
        
        //name分のトラックを表示
        var yAxis = chart.yAxes.push(am5xy.CategoryAxis.new(root, {
          categoryField: "name",
          renderer: yRenderer,
          fillRule: function (dataItem) {
            const axisFill = dataItem.get("axisFill");
            if (axisFill) {
              axisFill.set("visible", true);
            }
          }
        }));
        
        //トラックの距離を設定
        var xAxis = chart.xAxes.push(am5xy.ValueAxis.new(root, {
          renderer: xRenderer,
          min: 0,
          max: 1000,
          strictMinMax: true
        }));
        
        //Series(データポイント)の設定
        var series = chart.series.push(am5timeline.CurveColumnSeries.new(root, {
          maskBullets: false,
          xAxis: xAxis,
          yAxis: yAxis,
          valueXField: "value",
          categoryYField: "name",
          tooltip: am5.Tooltip.new(root, {
            labelText: "{category}: {value}"
          })
        }))
        
        series.columns.template.setAll({
          fill: am5.color(0xffffff),
          strokeOpacity: 0,
          fillOpacity: 0.5,
          layer: 30,
          interactive: true
        });
        
        //マウスを凡例にホバーした時のトラック上の動作
        series.columns.template.states.create("hover", {
          fillOpacity: 1
        });
        
        //Bulletの描画
        series.bullets.push(function (root, series, dataItem) {
          return am5.Bullet.new(root, {
            sprite: am5.Circle.new(root, {
              radius: 5,
              fillOpacity: 1,
              fill: am5.color(0xffffff)
            })
          });
        });
        
        //データをセット
        var data = ds;
        yAxis.data.setAll(data);
        series.data.setAll(data);
        series.appear(3000, 100);
        
        // 凡例の設定
        var legend = chart.curveContainer.children.push(am5.Legend.new(root, {
          centerX: am5.p50,
          centerY: am5.p50,
          useDefaultMarker: true,
          nameField: "categoryY",
          maxWidth: 350  
        }));
        
        //凡例の表示と動作の設定
        legend.valueLabels.template.setAll({
          forceHidden: true
        });
        
        legend.markers.template.setup = function (marker) {
          marker.events.on("dataitemchanged", function () {
            var dataItem = marker.dataItem
            var seriesDataItem = dataItem.dataContext;
            var picture = marker.children.push(am5.Picture.new(root, {
              width: 30,
              height: 30,
              src: seriesDataItem.dataContext.file
            }));
        
            marker.set("width", 30);
            marker.set("height", 30);
        
            dataItem.on("markerRectangle", function (rectangle) {
              rectangle.set("forceHidden", true);
            })
          });
        }
        
        //マウスポインターをトラック上にホバーした時とホバーを外した時の動作
        legend.itemContainers.template.events.on("pointerover", function (e) {
          var seriesDataItem = e.target.dataItem.dataContext;
          if (seriesDataItem) {
            var graphics = seriesDataItem.get("graphics");
            if (graphics) {
              graphics.hover();
            }
          }
        });
        
        legend.itemContainers.template.events.on("pointerout", function (e) {
          var seriesDataItem = e.target.dataItem.dataContext;
          if (seriesDataItem) {
            var graphics = seriesDataItem.get("graphics");
            if (graphics) {
              graphics.unhover();
            }
          }
        });
        
        //実際に設定して有効化
        legend.data.setAll(series.dataItems);
        
        //Timelineチャートがトラックを形成するように計算
        function getPoints() {
          var points = [];
          var radius = 300;
          points.push({ x: 0, y: -300 });
          points.push({ x: 300, y: -300 });
        
          for (var k = 1; k < 50; k++) {
            var angle = -180 - k / 50 * 180;
            points.push({ y: radius * am5.math.cos(angle), x: 300 + radius * am5.math.sin(angle) });
          }
        
          points.push({ x: -300, y: 300 });
        
          for (var k2 = 1; k2 < 50; k2++) {
            var angle2 = -k2 / 50 * 180;
            points.push({ y: radius * am5.math.cos(angle2), x: -300 + radius * am5.math.sin(angle2) });
          }
          points.push({ x: 0, y: -300 });
          return points;
        }
    });
```

* `require[]` の中で、JavaScriptライブラリの場所を指定します。上記の例では CDN のアドレスを指定していますが、JS ライブラリを[ダウンロード](https://www.amcharts.com/download/)して利用することも可能です。
* `var root = am5.Root.new("chartdiv")` で、CSS で定義した id を指定します。
* アニメーションの動きを取り入れるために、ルートのテーマを `am5themes_Animated` に設定しています。
* 今回、Timeline チャートを加工してトラックを成形しています。y 軸をランナーの並びに利用しており、そのための設定が `yRenderer` 、 `yAxis` に対して施されています。
* 一方の x 軸は、トラックの距離に使用しています。そのための設定が `xRenderer` 、 `xAxis` に対して施されています。各箇所の詳細は、コード内のコメントアウトをご覧ください。
* `Series` はデータポイントのことで、トラック上のポイントの動きに関わります。
* `var data = ds` で、processData で生成した JSON の連想配列を、チャートに受け渡します。
* `yAxis.data.setAll(data) ` と `series.data.setAll(data) ` で、軸と Series にデータをセットします。
* `Legend` (凡例) をトラック内部に収め、アイコン画像を並べて表示し、ホバーの動きや、クリック時の動作を設定しています。
* `function getPoints()` では、タイムチャートをトラックの形に成型するための計算が記述されています。

## 3. 動作確認
* チャートを表示してみると、まずは一斉にトラックを全ランナー (営業担当) が走り出し、現状の距離 (営業成績) が表示されます。現在は、山田よし子さんが先頭を走っています。
![a.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/530916bb-091a-4b98-b276-e6a9662f2f9d.gif)

* トラック上や判例にマウスをホバーすると、該当箇所が強調されます。
![b.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/552f5362-3b60-4d03-bec4-c8c533c099d1.gif)

* 凡例のクリックで、データの選択、非選択が可能です。
![c.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/0eb1d7ff-eebd-4a6b-9309-d36ea329b8cb.gif)

* マウストラックを動かすと、トラックの表示距離が変わります。
![d.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/13217093-7883-4f20-849f-7fdac1362f9d.gif)

## 最後に
昭和の時代の営業部の様子といえば、壁に営業成績を貼り出して、営業どうしを競争させていたイメージが強いのは私だけでしょうか。ちなみに、その様子を Copilot に描いてもらったのがこちら。魁!!男塾風に描いていただきました。コンプライアンスが厳しい令和の時代にも、こんな職場はまだあったりするんでしょうか。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/54316e1c-3d34-446e-bd3a-6fc2c861104c.png)

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