ボロノイ図 ‐ Yellowfin と amCharts の連携

## はじめに
[amCharts](https://www.amcharts.com/) と連携して、ボロノイ図を作成したいと思います。
 
ボロノイ図は「平面上に複数のポイントが配置されている時、その平面内の点を、どの点に最も近いかによって分割してできる図（出所：[マプコン](https://www.mapcom.co.jp/)）」 だそうです。との説明を受けても、なんのこっちゃ分かりませんよね。
ということで、ボロノイ図の出来上がりを先に紹介します。[Yellowfin](https://yellowfin.co.jp/) と [amCharts](https://www.amcharts.com/) を連携すると、ドリルダウンやズームも可能なボロノイ図が出来上がります。下記の例は、各国地域の人口を示すボロノイ図で、地球全体からアジアやアフリカなどの地域にドリルダウンできます。
![wp.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/a3799282-7389-43e4-8e83-12d0d1220037.gif)

[Yellowfin ツリーマップ](https://wiki.yellowfin.co.jp/space/yfcurrent/1900632/%E3%83%84%E3%83%AA%E3%83%BC%E3%83%9E%E3%83%83%E3%83%97)に類似した機能性をもたらします。

## 事前準備
#### 1-1. ビューの作成まで
[Worldometer](https://www.worldometers.info/) から、2025 年 3 月時点の世界各国地域の人口データを入手し、下記のようなテーブル (件数 233 件) を作成しました。テーブル作成に用いたデータを[こちら](https://github.com/ChasingHH/ShareFiles/blob/Share/World%20population)に共有しているので、よろしければご活用ください。

|region|id|country|population|
|---|---|---|---|
|Asia|IN|India|1463865525|
|Asia|CN|China|1416096094|
|Asia|JP|Japan|123103479|
|Latin America and the Caribbean|JM|Jamaica|2837077|
|Northern America|GL|Greenland|55745|

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

## 2. グラフの作成
準備が整ったら、グラフの作成に手順を進めます。[新規でレポートを作成](https://wiki.yellowfin.co.jp/space/yfcurrent/1900685/%E3%83%AC%E3%83%9D%E3%83%BC%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90)し、以下の手順に従ってグラフを作成します。
#### 2-1. [データ] ステップ
region、id、country、population をテーブルに配置します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/e4a262ff-f725-4d74-961d-7b3a609413b1.png)

#### 2-2. [グラフ] ステップ
[グラフ] ステップに進み、画面右側 [グラフの選択] から [JavaScriptグラフ] を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/30050cef-c6d5-4afa-bedb-4a782fb481a7.png)

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

processAfrica = function(dataset) {
    var ds_africa = [];
    for (i=0;i<dataset.country.length;i++){
        if(dataset.region[i].raw_data == "Africa"){
            ds_africa.push({
                id: dataset.id[i].raw_data,
                name: dataset.country[i].raw_data,
                population: dataset.population[i].raw_data,
            });
        }
    }
    console.log(JSON.stringify(ds_africa));
    return ds_africa;
},

processAsia = function(dataset) {
    var ds_asia = [];
    for (j=0;j<dataset.country.length;j++){
        if(dataset.region[j].raw_data == "Asia"){
            ds_asia.push({
                id: dataset.id[j].raw_data,
                name: dataset.country[j].raw_data,
                population: dataset.population[j].raw_data,
            });
        }
    }
    return ds_asia;
},

processEurope = function(dataset) {
    var ds_europe = [];
    for (k=0;k<dataset.country.length;k++){
        if(dataset.region[k].raw_data == "Europe"){
            ds_europe.push({
                id: dataset.id[k].raw_data,
                name: dataset.country[k].raw_data,
                population: dataset.population[k].raw_data,
            });
        }
    }
    return ds_europe;
},

processLatin = function(dataset) {
    var ds_latin = [];
    for (l=0;l<dataset.country.length;l++){
        if(dataset.region[l].raw_data == "Latin America and the Caribbean"){
            ds_latin.push({
                id: dataset.id[l].raw_data,
                name: dataset.country[l].raw_data,
                population: dataset.population[l].raw_data,
            });
        }
    }
    return ds_latin;
},

processNA = function(dataset) {
    var ds_na = [];
    for (m=0;m<dataset.country.length;m++){
        if(dataset.region[m].raw_data == "Northern America"){
            ds_na.push({
                id: dataset.id[m].raw_data,
                name: dataset.country[m].raw_data,
                population: dataset.population[m].raw_data,
            });
        }
    }
    return ds_na;
},

processOceania = function(dataset) {
    var ds_oceania = [];
    for (n=0;n<dataset.country.length;n++){
        if(dataset.region[n].raw_data == "Oceania"){
            ds_oceania.push({
                id: dataset.id[n].raw_data,
                name: dataset.country[n].raw_data,
                population: dataset.population[n].raw_data,
            });
        }
    }
    return ds_oceania;
},

doDrawing = function($chartDrawDiv, ds_africa, ds_asia, ds_europe, ds_latin, ds_na, ds_oceania) {
    require(['https://cdn.amcharts.com/lib/5/index.js',
            'https://cdn.amcharts.com/lib/5/hierarchy.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");
        
        // カスタムテーマの定義
        var myTheme = am5.Theme.new(root);
        
        myTheme.rule("Polygon", ["hierarchy", "node", "shape", "depth0"]).setAll({
          strokeOpacity: 0,
          fillOpacity: 0
        });
        
        myTheme.rule("Polygon", ["hierarchy", "node", "shape", "depth1"]).setAll({
          strokeWidth: 5,
          fillOpacity: 1,
          stroke: am5.color(0x000000)
        });
        
        myTheme.rule("Polygon", ["hierarchy", "node", "shape", "depth2"]).setAll({
          fillOpacity: 0,
          strokeWidth: 1,
          stroke: am5.color(0x000000)
        });
        
        myTheme.rule("HierarchyNode", ["last"]).setAll({
          cursorOverStyle: "pointer"
        });
        
        myTheme.rule("Label", ["node"]).setAll({
          fontSize: 11,
          minScale: 0.7
        });
        
        myTheme.rule("Label", ["node", "depth0"]).setAll({
          forceHidden: true
        });
        
        myTheme.rule("Label", ["node", "depth1"]).setAll({
          forceHidden: true
        });
        
        // テーマの設定
        root.setThemes([
          am5themes_Animated.new(root),
          myTheme
        ]);
        
        // データの作成
        var data = {
          "children": [
            {
              "name": "Africa",
              "children": ds_africa
            },
            {
              "name": "Asia",
              "children": ds_asia
            },
            {
              "name": "North America",
              "children": ds_na
            },
            {
              "name": "Europe",
              "children": ds_europe
            },
            {
              "name": "Latin America and the Caribbean",
              "children": ds_latin
            },
            {
              "name": "Oceania",
              "children": ds_oceania
            }
          ]
        };
        
        // 人口の少ない国地域をOthersに集約
        am5.array.each(data.children, function (continent) {
          var others = {
            name: "Others",
            id: "Others",
            population: 0
          };
        
          for (var i = continent.children.length - 1; i >= 0; i--) {
            var country = continent.children[i];
            if (country.population < 5000000) {
              others.population += country.population
              am5.array.remove(continent.children, country);
            }
          }
          continent.children.push(others);
        });
        
        // ズームの設定
        var zoomableContainer = root.container.children.push(
          am5.ZoomableContainer.new(root, {
            width: am5.p100,
            height: am5.p100,
            wheelable: true,
            pinchZoom: true
          })
        );
        
        var zoomTools = zoomableContainer.children.push(am5.ZoomTools.new(root, {
          target: zoomableContainer
        }));
        
        // シリーズの作成
        var series = zoomableContainer.contents.children.push(am5hierarchy.VoronoiTreemap.new(root, {
          paddingLeft: 5,
          paddingRight: 5,
          paddingTop: 5,
          paddingBottom: 5,
          singleBranchOnly: true,
          downDepth: 2,
          upDepth: 0,
          initialDepth: 2,
          valueField: "population",
          categoryField: "name",
          childDataField: "children",
          idField: "name",
          type: "polygon",
          cornerCount: 120
        }));
        
        // 正式名称で表示するか略称で表示するかの選択
        series.labels.template.adapters.add("x", function (x, target) {
          var dataItem = target.dataItem;
          if (dataItem) {
            var polygon = dataItem.get("polygon");
            if (polygon) {
              var minX = polygon.getPrivate("minX", 0);
              var maxX = polygon.getPrivate("maxX", 0);
              var dataContext = dataItem.dataContext;
              if (dataContext) {
                if (maxX - minX < 50) {
                  target.set("text", dataContext.id);
                }
                else {
                  target.set("text", dataContext.name);
                }
              }
            }
          }
          return x;
        });
        
        // 子階層でクリックすると親階層にドリルアップする動き
        series.nodes.template.events.on("click", function (e) {
          var dataItem = e.target.dataItem;
          if (dataItem) {
            if (!dataItem.get("children")) {
              series.selectDataItem(dataItem.get("parent"));
            }
          }
        });
        
        // データのセット
        series.data.setAll([data]);
        
        // ルートノードの選択
        series.set("selectedDataItem", series.dataItems[0]);
        
        // ロード
        series.appear(1000, 100);
        
        });
    });
};
```

プレビューをすると、以下のようなボロノイ図が表示されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/28628f60-a98b-45bf-ac4c-8f091e589cf0.png)

## コード

##### generateChart
```JavaScript:generateChart
generateChart = function(options) {
    var $chartDrawDiv = $(options.divSelector);
    var processedAfrica = processAfrica(options.dataset.data);
    var processedAsia = processAsia(options.dataset.data);
    var processedEurope = processEurope(options.dataset.data);
    var processedLatin = processLatin(options.dataset.data);
    var processedNA = processNA(options.dataset.data);
    var processedOceania = processOceania(options.dataset.data);
    doDrawing($chartDrawDiv, processedAfrica, processedAsia, processedEurope, processedLatin, processedNA, processedOceania);
},
```

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

##### processAfrica
アフリカのデータをデータセットに格納します。
```JavaScript:processAfrica
processAfrica = function(dataset) {
    var ds_africa = [];
    for (i=0;i<dataset.country.length;i++){
        if(dataset.region[i].raw_data == "Africa"){
            ds_africa.push({
                id: dataset.id[i].raw_data,
                name: dataset.country[i].raw_data,
                population: dataset.population[i].raw_data,
            });
        }
    }
    console.log(JSON.stringify(ds_africa));
    return ds_africa;
},
```

アフリカのデータとしては、以下のようなデータが出来上がります。
生成されるデータの中身を確認したい場合は、`console.log(JSON.stringify(ds_africa))` で、データをコンソールに出力しておくと便利です。
```JSON:ds_africa
[{"id":"AO","name":"Angola","population":39040039},
{"id":"BF","name":"Burkina Faso","population":24074580},
{"id":"BI","name":"Burundi","population":14390003},
…}]
```

同様に、processAsia、processEurope、processLatin、processNA、processOceania で、それぞれアジア、欧州、ラテンアメリカ・カリビアン、北米、オセアニアに関するデータを、各データセットに格納します。

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

##### doDrawing
チャートの描画に関わる処理を記述します。
```JavaScript:doDrawing
doDrawing = function($chartDrawDiv, ds_africa, ds_asia, ds_europe, ds_latin, ds_na, ds_oceania) {
    require(['https://cdn.amcharts.com/lib/5/index.js',
            'https://cdn.amcharts.com/lib/5/hierarchy.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");
        
        // カスタムテーマの定義
        var myTheme = am5.Theme.new(root);
        
        myTheme.rule("Polygon", ["hierarchy", "node", "shape", "depth0"]).setAll({
          strokeOpacity: 0,
          fillOpacity: 0
        });
        
        myTheme.rule("Polygon", ["hierarchy", "node", "shape", "depth1"]).setAll({
          strokeWidth: 5,
          fillOpacity: 1,
          stroke: am5.color(0x000000)
        });
        
        myTheme.rule("Polygon", ["hierarchy", "node", "shape", "depth2"]).setAll({
          fillOpacity: 0,
          strokeWidth: 1,
          stroke: am5.color(0x000000)
        });
        
        myTheme.rule("HierarchyNode", ["last"]).setAll({
          cursorOverStyle: "pointer"
        });
        
        myTheme.rule("Label", ["node"]).setAll({
          fontSize: 11,
          minScale: 0.7
        });
        
        myTheme.rule("Label", ["node", "depth0"]).setAll({
          forceHidden: true
        });
        
        myTheme.rule("Label", ["node", "depth1"]).setAll({
          forceHidden: true
        });
        
        // テーマの設定
        root.setThemes([
          am5themes_Animated.new(root),
          myTheme
        ]);
        
        // データの作成
        var data = {
          "children": [
            {
              "name": "Africa",
              "children": ds_africa
            },
            {
              "name": "Asia",
              "children": ds_asia
            },
            {
              "name": "North America",
              "children": ds_na
            },
            {
              "name": "Europe",
              "children": ds_europe
            },
            {
              "name": "Latin America and the Caribbean",
              "children": ds_latin
            },
            {
              "name": "Oceania",
              "children": ds_oceania
            }
          ]
        };
        
        // 人口の少ない国地域をOthersに集約
        am5.array.each(data.children, function (continent) {
          var others = {
            name: "Others",
            id: "Others",
            population: 0
          };
        
          for (var i = continent.children.length - 1; i >= 0; i--) {
            var country = continent.children[i];
            if (country.population < 5000000) {
              others.population += country.population
              am5.array.remove(continent.children, country);
            }
          }
          continent.children.push(others);
        });
        
        // ズームの設定
        var zoomableContainer = root.container.children.push(
          am5.ZoomableContainer.new(root, {
            width: am5.p100,
            height: am5.p100,
            wheelable: true,
            pinchZoom: true
          })
        );
        
        var zoomTools = zoomableContainer.children.push(am5.ZoomTools.new(root, {
          target: zoomableContainer
        }));
        
        // シリーズの作成
        var series = zoomableContainer.contents.children.push(am5hierarchy.VoronoiTreemap.new(root, {
          paddingLeft: 5,
          paddingRight: 5,
          paddingTop: 5,
          paddingBottom: 5,
          singleBranchOnly: true,
          downDepth: 2,
          upDepth: 0,
          initialDepth: 2,
          valueField: "population",
          categoryField: "name",
          childDataField: "children",
          idField: "name",
          type: "polygon",
          cornerCount: 120
        }));
        
        // 正式名称で表示するか略称で表示するかの選択
        series.labels.template.adapters.add("x", function (x, target) {
          var dataItem = target.dataItem;
          if (dataItem) {
            var polygon = dataItem.get("polygon");
            if (polygon) {
              var minX = polygon.getPrivate("minX", 0);
              var maxX = polygon.getPrivate("maxX", 0);
              var dataContext = dataItem.dataContext;
              if (dataContext) {
                if (maxX - minX < 50) {
                  target.set("text", dataContext.id);
                }
                else {
                  target.set("text", dataContext.name);
                }
              }
            }
          }
          return x;
        });
        
        // 子階層でクリックすると親階層にドリルアップする動き
        series.nodes.template.events.on("click", function (e) {
          var dataItem = e.target.dataItem;
          if (dataItem) {
            if (!dataItem.get("children")) {
              series.selectDataItem(dataItem.get("parent"));
            }
          }
        });
        
        // データのセット
        series.data.setAll([data]);
        
        // ルートノードの選択
        series.set("selectedDataItem", series.dataItems[0]);
        
        // ロード
        series.appear(1000, 100);
        
        });
    });
};
```

* `require[]` の中で、JavaScriptライブラリの場所を指定します。上記の例では CDN のアドレスを指定していますが、JS ライブラリを[ダウンロード](https://www.amcharts.com/download/)して利用することも可能です
* `var root = am5.Root.new("chartdiv")` で、CSS で定義した id を指定します
* `myTheme` は既定のテーマをオーバーライドするためのカスタムテーマです。`mytheme.rule` で、ドリルダウンの各階層での既定の見え方や動作を設定しています。rule として設定可能な項目に関しては、[Creating themes](https://www.amcharts.com/docs/v5/concepts/themes/creating-themes/) をご参照ください
* `root.setThemes` で、動きのある Animated テーマと、上記で定義したカスタムテーマを設定しています
* `var data = {}` の中で、ドリルダウン可能な多階層のデータ構造を定義しています。Africa や Asia を親階層に、各国地域を子階層とするデータ構造で、子階層には地域別に作成したデータセットを指定しています
* `am5.array.each(data.children, function (continent) {}` の中では、人口が 500 万人未満の国地域を Others にまとめています。233 要素を全て表示させると大変なことになるので。蛇足ですが、1 人当たり GDP が高いルクセンブルグも、人口が少ないため Others にまとまってしまいました。さらに蛇足ですが、私の知り合いのドイツ人は、一家でルクセンブルグに移り住みました
* `var zoomableContainer` と `var zoomTools` で、ズーム機能を設定しています。詳細は、[ZoomableContainer](https://www.amcharts.com/docs/v5/reference/zoomablecontainer/) をご参照ください
* `var series` では、チャートの表示関わる設定をしています。ちなみに、`type: "rectangle"` に変更すると、下のような四角の見た目に変わります。その他の設定は、[こちら](https://www.amcharts.com/docs/v5/reference/series/)でご確認ください
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2128512/20a1d56d-c166-4e1f-9bc0-75455ccd65e4.png)

* `series.labels.template.adapters.add("x", function (x, target) {}` では、ポリゴンのサイズによって、正式名 (country) を表示するか、略称 (id) を表示するかを制御しています
* `series.nodes.template.events.on("click", function (e) {}` では、子階層でクリックした際の動作を規定しています。親階層にドリルアップする動きです

* 最後に `series.data.setAll([data])` でデータをセットし、`series.set("selectedDataItem"、series.dataItems[0])` ルートノードを選択し、`series.appear(1000, 100) ` でロードしてチャートを表示します

## 最後に
ボロノイ図の名称は、ロシア人数学者ゲオルギ・フェドセビッチ・ボロノイ(出所：[Wikipedia](https://en.wikipedia.org/wiki/Georgy_Voronoy)) に因んだものだそうです。1908 年に 40 歳という若さで他界してしまったそうな。ボロノイ図って、なんだかんだ長い歴史のあるチャートなんですね。

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