YellowfinとMapbox GL JSを使ってヒートマップを表示する

YellowfinにはJavaScriptを直接記述することで自由度の高いビジュアライズが実現できる**「JavaScriptグラフ」**というそのものズバリな名前の機能があります。
この記事ではJavaScriptグラフの機能を使って、開発者に人気の高い地図サービスである [Mapbox](https://www.mapbox.com/)を組み込んでみたいと思います。

# Mapboxとは

地図サービスといえば真っ先に思いつくのはGoogleマップですが、Mapboxはそれよりも高いカスタマイズ性を売りにしています。また、開発者向けにJavaScriptのライブラリや、iOS・Android用のSDKも提供されており、作成した地図を様々な用途で使用することができます。

OpenStreetMapをベースの地図に使用しているため、著作権を気にする必要がありません。用途に応じて地図のデザインを変更したり、必要な情報をラインやポリゴンで追加したりと、自由に改変することが可能です。作成した地図はMapbox上に保存し、さまざまなWebサイトやシステムで活用することができます。

# レポート作成の準備

## Mapboxのアクセストークン取得

まずはMapboxに[サインアップ](https://account.mapbox.com/auth/signup/)してアカウントを作成し、アクセストークンを取得します。

![mapbox1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116818/c1c3f40a-c737-1b8c-074c-46d03b5496d7.png)

発行されたアクセストークンは後で使います。

## JavaScriptグラフの有効化

YellowfinでJavaScriptグラフを使用するためには、以下2点の設定が必要です。

- 使用するロールのJavaScriptグラフ権限を有効にする
- システムセキュリティのJavaScriptグラフ設定を有効にする

具体的な方法はオンラインマニュアルの[JavaScriptグラフの有効化](https://wiki.yellowfin.co.jp/pages/viewpage.action?pageId=10945761)のページを参照してください。

# JavaScriptグラフの作成

[Mapbox GL JSでヒートマップを作成するチュートリアル](https://docs.mapbox.com/help/tutorials/make-a-heatmap-with-mapbox-gl-js/)を参考に、グリグリ動く地図を作りたいと思います。仕上がりはこんな感じになります。

![map.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116818/ada43e1b-2024-1e16-4e02-c7492b432786.gif)

- 最初はヒートマップで数値が高く、密度も高いほど赤く表示される
- ズームすると、各ポイントに数値の大小で色分けされた円を表示するレイヤーに移行する
- 円をクリックすると、プロパティが表示される

ではさっそく作っていきましょう。

## 表の作成

まずYellowfinで表を作成します。
使用するビューはデフォルトでインストールされるサンプルの**Ski Team**です。

「Athlete」フォルダにある**First Name**と**Last Name**、「Athlete Location」フォルダにある**Athelete Geo Point**、「Athlete Payment」フォルダにある**Invoiced Amount**をキャンバスへドラッグ＆ドロップします。

![mapbox2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116818/b50f90a3-bad8-15e5-b47c-8b9b5e6bb09c.png)

## グラフの作成

グラフ編集の画面に切り替えて、画面右端にある「JS」のボタンをクリックし、「JavaScriptグラフ」を選択します。
もしこのボタンが表示されていない場合は、前述のJavaScriptグラフの有効化設定がうまくできていません。その際はもう一度設定を見直してください。

![mapbox5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116818/1d3c6abf-2355-3d7b-d8fd-e850e52a3f8f.png)

そして、「JavaScript」及び「CSS」の記述を以下のコードに置き換えてください。**mapboxのアクセストークン**の箇所には前述のアクセストークンを貼り付けます。

```javascript:yf-mapbox-heatmap.js

//generateChartは必須の関数です。Javascriptグラフの作成において呼び出されます。
generateChart = function(options) {

    // requireを使用して必要なJavascriptライブラリを読み込み
    require(['https://api.mapbox.com/mapbox-gl-js/v1.3.1/mapbox-gl.js','https://d3js.org/d3.v4.min.js'], function(mapboxgl,d3) {

        // グラフの高さと幅を設定
        var h = 600;
        var w = 1000;
        var area = d3.select(options.divSelector)
                        .append("div")
                        .attr("id","map")
                        .style("height",h + 'px')
                        .style("width",w + 'px');
    
        // 作成した表のデータセットをGEOJSONに変換
        const jsonObj = geojsonObj(options.dataset.data);

        // mapboxのAPIを呼び出してmapオブジェクトを作成
        mapboxgl.accessToken = '（mapboxのアクセストークン）';
        var map = new mapboxgl.Map({
            container: 'map',
            style: 'mapbox://styles/mapbox/dark-v10',
            center: [150,30],
            zoom: 1
        });

        // mapの描画
        map.on('load', function() {
            // 上で変換したGEOJSONオブジェクトをmapのソースに追加
            map.addSource('invoice', {
                "type": "geojson",
                "data": jsonObj
            });

            // ヒートマップレイヤーを追加
            map.addLayer({
                "id": "invoiced-heat",
                "type": "heatmap",
                "source": "invoice",
                "maxzoom": 9,
                "paint": {
                    // invoicedの値に基づいてヒートマップの重みを増加させる
                    "heatmap-weight": [
                        "interpolate",
                        ["linear"],
                        ["get", "Invoiced"],
                        0, 0,
                        10000, 1
                    ],
                    // ズームレベルでヒートマップのカラーウェイトを増やす
                    // heatmap-intensityはheatmap-weightの乗数
                    "heatmap-intensity": [
                        "interpolate",
                        ["linear"],
                        ["zoom"],
                        0, 1,
                        9, 3
                    ],
                    // ヒートマップのカラーランプ。範囲は0(低い)〜1(高い)
                    // 0の分岐点で色ランプを開始し、透過色は0
                    // ブラーのようなエフェクトを作成します
                    "heatmap-color": [
                        "interpolate",
                        ["linear"],
                        ["heatmap-density"],
                        0, "rgba(33,102,172,0)",
                        0.2, "rgb(103,169,207)",
                        0.4, "rgb(209,229,240)",
                        0.6, "rgb(253,219,199)",
                        0.8, "rgb(239,138,98)",
                        1, "rgb(178,24,43)"
                    ],
                    // ズームレベルでヒートマップ半径を調整する
                    "heatmap-radius": [
                        "interpolate",
                        ["linear"],
                        ["zoom"],
                        0, 2,
                        9, 20
                    ],
                    // ズームレベルによるヒートマップからサークルレイヤーへの移行
                    "heatmap-opacity": [
                        "interpolate",
                        ["linear"],
                        ["zoom"],
                        7, 1,
                        9, 0
                    ],
                }
            }, 'waterway-label');  

            // サークルレイヤーを追加
            map.addLayer({
                "id": "invoice-point",
                "type": "circle",
                "source": "invoice",
                "minzoom": 7,
                "paint": {
                    // invoicedの値とズームレベルによって円の半径をサイズ変更
                    "circle-radius": [
                        "interpolate",
                        ["linear"],
                        ["zoom"],
                        7, [
                            "interpolate",
                            ["linear"],
                            ["get", "Invoiced"],
                            0, 1,
                            10000, 4
                        ],
                        16, [
                            "interpolate",
                            ["linear"],
                            ["get", "Invoiced"],
                            0, 5,
                            10000, 50
                        ]
                    ],
                    // invoicedの値によって円の色を変更
                    "circle-color": [
                        "interpolate",
                        ["linear"],
                        ["get", "Invoiced"],
                        0, "rgba(33,102,172,0)",
                        10000, "rgb(103,169,207)",
                        20000, "rgb(209,229,240)",
                        50000, "rgb(253,219,199)",
                        70000, "rgb(239,138,98)",
                        100000, "rgb(178,24,43)"
                    ],
                    "circle-stroke-color": "white",
                    "circle-stroke-width": 1, 
                    // ズームレベルによるヒートマップからサークルレイヤーへの移行
                    "circle-opacity": [
                        "interpolate",
                        ["linear"],
                        ["zoom"],
                        7, 0,
                        8, 1
                    ]
                }
            }, 'waterway-label');
        });
    
        // サークルレイヤーをクリック時にポップアップ表示
        map.on('click', 'invoice-point', function(e) {
            new mapboxgl.Popup()
            .setLngLat(e.features[0].geometry.coordinates)
            .setHTML('<b>Name:</b> ' + e.features[0].properties.Name + '<br/><b>Invoiced:</b> $' + e.features[0].properties.Invoiced)
            .addTo(map);
        });
    });
},

// データセットをJSON形式のオブジェクトに変換
geojsonObj = function(dataset) {
    // データセットから各カラムの値の配列を取得
    var rawPoints = dataset.athlete_geo_point;
    var invoices = dataset.invoiced_amount;
    var lastNames = dataset.last_name;
    var firstNames = dataset.first_name;
    
    // GEOJSON形式のテキストを作成
    var jsontext = '{"type": "FeatureCollection","features": [ ';
    // レコードの数だけループ
    for (var i = 0; i < rawPoints.length; i++) {
        // 各カラムのraw_dataを取り出し。必要に応じて整形
        var latitude = rawPoints[i].raw_data.split(" ")[0].replace('POINT(','');
        var longitude = rawPoints[i].raw_data.split(" ")[1].replace(')','');
        var pointText = "[ " + longitude + " , " + latitude + " ]";
        var invoice = invoices[i].raw_data;
        var athleteName = firstNames[i].raw_data + ' ' + lastNames[i].raw_data;
        // テキストに追加
        jsontext = jsontext + '{ "type": "Feature", "properties": { "Invoiced": ' + invoice;
        jsontext = jsontext + ', "Name": "' + athleteName + '"';
        jsontext = jsontext + ' }, "geometry": { "type": "Point", "coordinates": ' + pointText + ' } },';
    }
    jsontext = jsontext.slice(0, -1) + ' ] }';
    // テキストをJSONオブジェクトに変換して返す
    return JSON.parse(jsontext);
};
```

```CSS:yf-mapbox.css
@import url("https://api.tiles.mapbox.com/mapbox-gl-js/v1.3.1/mapbox-gl.css");
```

これだけでもう完成なのですが、カスタマイズするときのポイントを以下にいくつか記しておきます。

### 地図のスタイル

```javascript

var map = new mapboxgl.Map({
  container: 'map',
  style: 'mapbox://styles/mapbox/dark-v10',
  center: [150,30],
  zoom: 1
});
```

**style**で地図のスタイルを指定できます。
今回の例ではdarkを使用しましたが、他にもいろいろ用意されています。

![mapbox6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116818/ef0b8c1a-697a-88c3-c4e9-5824787b1a05.png)

既存のスタイルをベースに**Mapbox Studio**で編集すれば、日本語表示を優先にすることも可能です。（以下の画像はlightを日本語表示にしたものです）

![mapbox7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116818/2f47f29d-5044-137c-1f13-876555b21a1d.png)

**center**は初期時に中心となる経度と緯度を、**zoom**はズームレベルをそれぞれ指定しています。

### ヒートマップの重み

```javascript

// invoicedの値に基づいてヒートマップの重みを増加させる
"heatmap-weight": [
  "interpolate",
  ["linear"],
  ["get", "Invoiced"],
  0, 0,
  10000, 1
],
```

Invoiced Amountの値に応じて0〜10000の範囲で重み付けを行っています。
どの範囲が適正かはデータに合わせて調整してください。

### 円の半径の調整

```javascript

// invoicedの値とズームレベルによって円の半径をサイズ変更
"circle-radius": [
  "interpolate",
  ["linear"],
  ["zoom"],
  7, [
    "interpolate",
    ["linear"],
    ["get", "Invoiced"],
    0, 1,
    10000, 4
  ],
  16, [
    "interpolate",
    ["linear"],
    ["get", "Invoiced"],
    0, 5,
    10000, 50
  ]
],
```

ここでもInvoiced Amountの値に応じて0〜10000の範囲で円の半径を調整しています。

### 円の色の調整

```javascript

// invoicedの値によって円の色を変更
"circle-color": [
  "interpolate",
  ["linear"],
  ["get", "Invoiced"],
  0, "rgba(33,102,172,0)",
  10000, "rgb(103,169,207)",
  20000, "rgb(209,229,240)",
  50000, "rgb(253,219,199)",
  70000, "rgb(239,138,98)",
  100000, "rgb(178,24,43)"
],
```

ここではInvoiced Amountの値に応じて0〜100000の範囲で円の色を塗り分けています。

他にも調整できるポイントはいろいろあるのですが、より詳しくは[Mapbox GL JSのドキュメント](https://docs.mapbox.com/mapbox-gl-js/api/)を参照してください。

### Yellowfinの表からJavaScriptへ渡すデータ

```javascript

// 作成した表のデータセットをGEOJSONに変換
const jsonObj = geojsonObj(options.dataset.data);

// （中略）

// データセットをJSON形式のオブジェクトに変換
geojsonObj = function(dataset) {
    // データセットから各カラムの値の配列を取得
    var rawPoints = dataset.athlete_geo_point;
    var invoices = dataset.invoiced_amount;
    var lastNames = dataset.last_name;
    var firstNames = dataset.first_name;
    
    // GEOJSON形式のテキストを作成
    var jsontext = '{"type": "FeatureCollection","features": [ ';
    // レコードの数だけループ
    for (var i = 0; i < rawPoints.length; i++) {
        // 各カラムのraw_dataを取り出し。必要に応じて整形
        var latitude = rawPoints[i].raw_data.split(" ")[0].replace('POINT(','');
        var longitude = rawPoints[i].raw_data.split(" ")[1].replace(')','');
        var pointText = "[ " + longitude + " , " + latitude + " ]";
        var invoice = invoices[i].raw_data;
        var athleteName = firstNames[i].raw_data + ' ' + lastNames[i].raw_data;
        // テキストに追加
        jsontext = jsontext + '{ "type": "Feature", "properties": { "Invoiced": ' + invoice;
        jsontext = jsontext + ', "Name": "' + athleteName + '"';
        jsontext = jsontext + ' }, "geometry": { "type": "Point", "coordinates": ' + pointText + ' } },';
    }
    jsontext = jsontext.slice(0, -1) + ' ] }';
    // テキストをJSONオブジェクトに変換して返す
    return JSON.parse(jsontext);
};
```

ここでデータをいろいろ加工しています。
**options.dataset.data**の中にデータが入っているのですが、詳しくは[オンラインマニュアル](https://wiki.yellowfin.co.jp/pages/viewpage.action?pageId=10945756)や[こちらのQiitaの記事](https://qiita.com/celery/items/d68e1e480e7455e1ecf4)を参照してください。

Ski Teamのデータでは顧客の位置情報が*POINT(緯度 経度)*という書式で登録されていましたので、splitで分割した上で必要部分だけ取り出しています。

そして[GeoJSONのフォーマット](https://ja.wikipedia.org/wiki/GeoJSON)に整形したテキストを、**JSON.parse**でJSONオブジェクトへ変換しています。

# まとめ

JavaScriptグラフは自身でコードを書く必要があるため若干ハードルは高いですが、Mapboxのような外部サービスを容易に組み込むことが可能で、使い方次第では非常に強力な機能です。
Yellowfinでは困難だと思っていたビジュアライズが実現できるかもしれませんよ？？？