MapLibre GL JSのGlobe Projectionでコロプレス地球儀を作る

# MapLibre GL JSのGlobe Projection

ウェブ地図描画ライブラリのMapLibre GL JSでは、v5のPreバージョンでGlobe Projection（ウェブ地図を地球儀の形で投影する表現オプション）が実装されました。
以前の記事（[リンク](https://qiita.com/sanskruthiya/items/dcd26d86ba8d664e4d57)）では、これを使ってダイナミックに地球を周遊するストーリーマップの例を紹介しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122865/3c0a04a8-de6f-7cb8-0097-cca134361d64.png)

本記事は、その他のユースケースとしてコロプレス地球儀の例を紹介します。

# コロプレス地球儀

今回紹介するのは、地理情報の可視化における最も基本的な表現技法と言える統計色分け地図、いわゆる「コロプレス図」です。
例えば、国連開発計画（UNDP）が公開している各国の開発度合いを示す「[人間開発指数（Human Development Index）](https://hdr.undp.org/data-center/human-development-index#/indicies/HDI)」の数値による全世界の色分け地図だとこのようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122865/661119b6-3db0-8157-204a-a967ce117a42.png)


このような統計色分け世界地図も、Globe Projectionを使うことで、**よりフェアに・よりダイナミックに表現**することができます。

こちらが同じデータを地球儀化したものです。（[デモへのリンク](https://kashiwa.co-place.com/cmap/globe/)）
上図と比較して、ロシアなどの印象が結構変わってくることがわかります。
なお、**JavaScriptのコードはこの記事の後半に記載**しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122865/210ffd54-bdff-57d2-02a6-ffa42f9ba416.png)

平面地図と地球儀でそれぞれ長所・短所はありますが、**平面地図だと緯度によって面積が誇張されてしまう故のアンフェアな見栄えが地球儀に投影（Globe Projection）することで解消される**のは大きなメリットだと思います。

また、地球儀の反対側は回転させないと見えないのがネックですが、「地図を動かしながら情報を探索する」というウェブ地図ならではの体験価値を考慮すると、デメリットとも言えないかと思います。


# 極点の表現について

この地球儀をグルっと回して極点を見てみると、南極大陸もきちんと描画されているのが興味深いなと思います。
（きちんと調べきれていないので恐縮ですが）元データをそのまま地球儀の形に変換すると「てっぺんハゲ」みたいな描画になる気がしますが、それをMapLibre側でうまく解消しているのかもしれません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122865/bc55ec7b-9f2f-f1de-3eba-d50536b65e1d.png)

参考までに、以前にDeckGLのGlobeViewで似たようなコロプレス図を作った際には極点がこのようになっていたので、MapLibre側の効果であれば地味にありがたいものだと思います。
（[下記のデモサイトへのリンクはこちら](https://kashiwa.co-place.com/cmap/itpr-wf/)）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122865/95fca486-5000-0923-70ff-3ebad52586a2.png)


# Sky機能との併用について

MapLibre GL JSのGlobe Projectionは、一部機能と併用すると問題が生じるケースもあります。一例として挙げられるのがSky機能（空の描画）との併用です。
Skyを設定すると、下図のように月食みたいな表現になってしまいます。ズームレベルに応じてSkyのオンオフを切り替えれば併用できるかもしれませんが、私の方では試せていません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122865/1a6c894a-48b7-b22e-d540-b3c3a269031e.png)

# 3D表示との併用について
3D（fill-extrusionレイヤ）表示との併用も試してみました。こちらは問題なく表示できるようです。統計地図を3Dで表現するとかえってわかりづらくなりそうですが、建物データなどを拡大時に立体表示させるといった基本的な表現方法は問題なく動作しそうです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122865/c6c0d7e4-56ed-639e-253f-3961c730a3b2.png)


# 今後の進展の予想

Globe Projectionは用途によってはかなり重要な可視化オプションになると思います。既にMapboxやDeckGLなどは早い段階でこれが実装されているため、その後追いとなる機能追加が今後進むのかなと予想しています。
特に、背景色のオプションや時間帯に応じた昼夜の表現、Skyなど一部機能のズームレベルに応じた自動切り替えなどが簡単に設定できるようになると便利そうです。

参考までに、DeckGLの例だとこんな感じ（[公式サイトへのリンク](https://deck.gl/examples/globe-view)）で時間帯に応じた昼夜表現が可能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122865/040b0db8-500d-fe1b-14b6-1610ac986d11.png)

# MapLibre GL JSでの実装コード

上記で示したコロプレス地球儀のMapLibre GL JSでの実装コードを記載します。
なおGlobe Projectionと直接関連しない工夫点として、極小面積の国（島嶼国など）の情報が確認しづらい問題に対して、シンボルラベルのクリックでもポリゴンレイヤのクリックと同じポップアップ情報を表示するアプローチを取りました。

```javascript:index.js
import * as maplibregl from "maplibre-gl";
import 'maplibre-gl/dist/maplibre-gl.css';
import './style.css';

//指標値に基づく色分け設定
const categoryMap = {
    1: 'Low（後発開発途上国相当）',
    2: 'Medium（開発途上国相当）',
    3: 'High（新興国相当）',
    4: 'Very High（先進国相当）'
};

const colorMap = {
    1: '#2b83ba',
    2: '#ddf1b4',
    3: '#f59053',
    4: '#d7191c',
    0: '#fff'
};

function getCategory(d) {
    return categoryMap[d] || '-(対象外)';
}

function getColor(d) {
    return colorMap[d] || '#fff';
}

//凡例生成
const zoning_legend = document.getElementById('zoning-legend'); //HTMLファイルのdiv要素id="zoning-legend"に対応
const matchColor = ["match", ["get", "st_HDI_category"], 1, getColor(1), 2, getColor(2), 3, getColor(3), 4, getColor(4), getColor(0)];
Object.entries(categoryMap).forEach(([key, value]) => {
    zoning_legend.innerHTML += `<i style="background:${getColor(Number(key))}"></i> ${value}<br>`;
});
zoning_legend.innerHTML += '<a href="https://hdr.undp.org/data-center/human-development-index" target="_blank">Source: Human Development Index 2022</a>';

//マップの初期表示設定
const init_center = [139.93003, 35.72164];
const init_zoom = 2;
const init_bearing = 0;
const init_pitch = 0;

//マップの基本設定
const map = new maplibregl.Map({
    container: 'map',
    style: 'https://tile.openstreetmap.jp/styles/osm-bright-ja/style.json',
    center: init_center,
    interactive: true,
    zoom: init_zoom,
    bearing: init_bearing,
    pitch: init_pitch,
    attributionControl: false,
    renderWorldCopies: false
});

//帰属情報の設定
const attCntl = new maplibregl.AttributionControl({
    customAttribution:'-',
    compact: true
});
map.addControl(attCntl, 'bottom-right');

//マップ上に描画する各種情報の読み込み
map.on('load', () => {
    map.setProjection({"type": "globe"}); //ここでGlobe Projectionを設定
    //国データの読み込み
    map.addSource('country-polygon', { //国のラフな形状ポリゴンにHDI指標値を紐づけたデータ
        'type': 'geojson',
        'data': './app/data/country250_HDI_polygon.geojson',
    });
    map.addSource('country-point', { //国の代表点ポイントにHDI指標値を紐づけたデータ
        'type': 'geojson',
        'data': './app/data/country250_HDI_point.geojson',
    });

    //各レイヤの読み込み
    map.addLayer({
        'id': 'country_polygon', //国のラフな形状ポリゴンにHDI指標値を紐づけたデータをfill（塗り）として読み込む
        'source': 'country-polygon',
        'maxzoom':10,
        'type': 'fill',
        'layout': {
            'visibility': 'visible',
        },
        'paint': {
            'fill-color': ["to-color", matchColor],
            'fill-outline-color': '#fff',
            'fill-opacity': ["interpolate",["linear"],["zoom"],1,1,3,0.9,6,0.1]
        }
    });
    map.addLayer({
        'id': 'country_point', //国の代表点ポイントにHDI指標値を紐づけたデータをシンボルラベルとして読み込む
        'type': 'symbol',
        'source': 'country-point',
        'filter': ['>', 'st_HDI_value', 0],
        "maxzoom": 10,
        'layout': {
            'icon-image': '',
            'text-field': ["format",['concat', ['get', 'st_Name_jp'],'(',['get', 'st_HDI_rank'],')'], { "font-scale": 1, 'text-color': '#111'}],
            'text-anchor': 'top',
            'text-allow-overlap': false,
            'text-font': ['Open Sans Semibold','Arial Unicode MS Bold'],
            'text-size': 11,
            'text-offset': [0, -0.5]
        },
        'paint': {'text-color': '#333','text-halo-color': '#fff','text-halo-width': 1}
    });
    /*
    //fill-extrusionによる3D試行用
    map.addSource('country-circle', {
        'type': 'geojson',
        'data': './app/data/country250_HDI_pointCircle.geojson',
    });
    map.addLayer({
        'id': 'country_3D',
        'source': 'country-circle',
        "maxzoom": 10,
        'layout': {
            'visibility': 'visible',
        },
        'type': 'fill-extrusion',
        'paint': {
            "fill-extrusion-color": ["to-color", matchColor],
            "fill-extrusion-opacity": 0.5,
            "fill-extrusion-height": ["*", ["get", "st_一人当たりGNI"], 10]
        }
    });
    //Sky描画試行用
    map.setSky({
        "sky-color": "#199EF3",
        "sky-horizon-blend": 0.7,
        "horizon-color": "#f0f8ff",
        "horizon-fog-blend": 0.8,
        "fog-color": "#2c7fb8",
        "fog-ground-blend": 0.9,
        "atmosphere-blend": ["interpolate",["linear"],["zoom"],0,1,12,0]
    });
    */
});

//ポップアップ時の表示内容の設定
function createPopup(feature, lngLat) {
    const content = `
        <h3>${feature.properties.st_Name_jp}</h3>
        <table class="tablestyle02">
            <tr><td>人間開発指標</td><td>${feature.properties.st_HDI_value > 0 ? 
                `${Number(feature.properties.st_HDI_value).toLocaleString()} (${feature.properties.st_HDI_rank}位)` : '- (対象外)'}</td></tr>
            <tr><td>分類</td><td>${getCategory(feature.properties.st_HDI_category)}</td></tr>
            <tr><td>平均寿命</td><td>${feature.properties.st_平均寿命 > 0 ? 
                `${(feature.properties.st_平均寿命).toFixed(2)} 歳` : '- (対象外)'}</td></tr>
            <tr><td>期待就学年数</td><td>${feature.properties.st_期待就学年数 > 0 ? 
                `${(feature.properties.st_期待就学年数).toFixed(2)} 年` : '- (対象外)'}</td></tr>
            <tr><td>平均就学年数</td><td>${feature.properties.st_平均就学年数 > 0 ? 
                `${(feature.properties.st_平均就学年数).toFixed(2)} 年` : '- (対象外)'}</td></tr>
            <tr><td>一人当たりGNI</td><td>${feature.properties.st_一人当たりGNI > 0 ? 
                `${Math.round(feature.properties.st_一人当たりGNI).toLocaleString()} $ (PPP 2017 intl$)` : '- (対象外)'}</td></tr>
        </table>
        <p class="remarks"><a href="https://www.google.com/search?q=国+${feature.properties.st_Name_jp}" target="_blank">この国についてGoogleで調べる</a></p>
    `;
    new maplibregl.Popup({ closeButton: true, focusAfterOpen: false, className: 't-popup', maxWidth: '360px', anchor: 'bottom' })
        .setLngLat(lngLat)
        .setHTML(content)
        .addTo(map);
}

//ポリゴンレイヤのクリックイベント
map.on('click', 'country_polygon', function (e) {
    //シンボルとポリゴンの両レイヤが重複してポップアップされないように分岐処理
    const symbolFeature = map.queryRenderedFeatures(e.point, { layers: ['country_point'] });
    if (!symbolFeature.length) {
        const polygonFeature = map.queryRenderedFeatures(e.point, { layers: ['country_polygon'] })[0];
        if (polygonFeature) {
            map.panTo(e.lngLat, { duration: 1000 });
            createPopup(polygonFeature, e.lngLat);
        }
    }
});

//シンボルレイヤのクリックイベント
map.on('click', 'country_point', function (e) {
    const pointFeature = map.queryRenderedFeatures(e.point, { layers: ['country_point'] })[0];
    if (pointFeature) {
        map.panTo(e.lngLat, { duration: 1000 });
        createPopup(pointFeature, e.lngLat);
    }
});

```

読み込んでいる国データはGeoJSON形式で独自に加工したものですが、確認されたい場合はこちら（[GitHubへのリンク](https://github.com/sanskruthiya/GlobalChoropleth/tree/main/dist/app/data)）からご覧ください。

# おわりに
今回は、MapLibre GL JSのversion5で実装が予定されているGlobe Projectionのユースケースについて、特に統計色分け図（コロプレス図）とそれを地球儀化する効果について紹介しました。
以前の記事で挙げたストーリーマップでの活用とあわせて、この2通りの使い方に対しては結構インパクトがあるのではと考えています。
勿論、Globe Projectionにはこれ以外にも有効な活用方法があると思いますので、今後も様々なユースケースを見ていきたいところです。