deck.glで今年飲んだワインの本数と色の割合をクラスタリングして地図上に表示してみた

この記事は [検索エンジンプロダクトを一緒に開発してた同窓会 Advent Calendar 2023](https://qiita.com/advent-calendar/2023/alumni) の2日目の記事です。

# はじめに
普段はバックエンドやインフラをやることが多いですが、今年はreact周りもちょこちょこ触ることが出来ました。
特に[deck.gl](https://deck.gl/)というマップ上でのデータ可視化ライブラリと組み合わせて使うことが多かったです。
今年からワインに惹かれ飲み始めたのですが、飲んだワインの産地や種類をマップ上で可視化できたらテンションがあがりそうだし、reactとdeck.glを学ぶのにうってつけだなと思い作ってみることにしました。

# 最終的なアウトプット
マップ上にこんな感じで表示出来るようになりました。

![wine.2023-12-01 21_59_39.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321756/eaa73a9f-4bd4-4bcc-1eba-f0b847e752ff.gif)

下記に使ったデータやその形式、また実装について書いていきます。

# データ準備
## 飲んだワインのデータ
上記のような表示をするために、どのワインを飲んだのか、そのワインの産地はどこで、何色を飲んだのかなどのデータが必要となります。
僕はワインを飲む度に色やぶどう品種、産地などをスプレッドシートにまとめていたのでそれを使うことしました！えらい！！！

データはこんな感じでまとめていました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321756/02bf2f90-e520-a0e6-7fd6-261966ebaff7.png)

あとはこれをcsvとして抽出するだけです。

```csv
preference,name,color,country,region
好き,ボーグル・ヴィンヤーズ / シャルドネ 2021,白,アメリカ,カルフォルニア州
苦手,ダーレンベルグ / ザ・スタンプ・ジャンプ レッド 2018,赤,オーストラリア,南オーストラリア州
好き,ペトローロ / ボッジナ アンフォラ 2015,赤,イタリア,トスカーナ州
苦手,カベルネ エニーラ 2020,赤,ブルガリア,トラキア平原地方
どちらでもない,ヴァス・フェリックス / フィリウス カベルネ・ソーヴィニヨン 2019,赤,オーストラリア,西オーストラリア州
苦手,ストリ・マラニ / サペラヴィ クヴェヴリ 2015,赤,ジョージア,カヘティ州
好き,シャトー・モーカイユ 2012,赤,フランス,ヌーヴェル＝アキテーヌ地域圏
好き,プリモシッチ / リボッラ・ジャッラ リゼルヴァ 2018,オレンジ,イタリア,フリウリ＝ヴェネツィア・ジュリア州
好き,ルイ・ラトゥール / ムルソー プルミエ・クリュ ポリュゾ 2006,白,フランス,ブルゴーニュ＝フランシュ＝コンテ地域圏
好き,クネ / コロナ 2015,白,スペイン,リオハ州
```

産地や色だけでなく、好きだったか、そのワインの味わいや香りはどうだったかなどもメモに取っていましたが、表示には必要なかったのカットしました。

## 国と地域の位置情報
飲んだワインの本数と色の割合を表示するためには、表示する位置情報も準備する必要があります。
表示位置は国あるいは地域の中央にきれば見栄えが良さそうだと思ったので、ChatGPTに聞いて、下記のコードを生成してもらいました。

```typescript:国の位置情報
export const countryCoordinates: { [country: string]: [number, number] } = {
    'アルゼンチン': [-63.6167, -38.4161],
    'チリ': [-71.5430, -35.6751],
    'ドイツ': [10.4515, 51.1657],
    'フランス': [2.2137, 46.2276],
    'イスラエル': [35.2137, 31.7683],
    'アメリカ': [-95.7129, 37.0902],
    'イタリア': [12.4964, 41.9028],
    'スペイン': [-3.7038, 40.4168],
    'カナダ': [-106.3468, 56.1304],
    'ニュージーランド': [174.8860, -40.9006],
    '中国': [104.1954, 35.8617],
    'ジョージア': [44.8271, 41.7151],
    'オーストラリア': [133.7751, -25.2744],
    'ポルトガル': [-8.2245, 39.3999],
    '日本': [138.2529, 36.2048],
    'ブルガリア': [25.4858, 42.7339],
    '南アフリカ': [22.9375, -30.5595],
};
```

```typescript:地域の位置情報
export const regionCoordinates: { [region: string]: [number, number] } = {
    // Argentina
    'サルタ州': [-65.7792, -24.7821],
    'メンドーサ州': [-68.8475, -32.8895],

    // Australia
    '南オーストラリア州': [136.2092, -30.0002],
    'ニューサウスウェールズ州': [146.9211, -31.8402],
    'ビクトリア州': [144.9631, -37.8136],
    '西オーストラリア州': [121.8910, -27.6728],

    // Bulgaria
    'トラキア平原地方': [25.4858, 42.7339],

    // Canada
    'ノバスコシア州': [-63.7443, 44.6810],

    // Chile
    'ビオビオ州': [-72.1023, -37.7307],
    'マウレ州': [-71.6747, -35.4264],

    // France
    'ラングドック＝ルシヨン地域圏': [3.0321, 43.6108],
    'サントル＝ヴァル・ド・ロワール地域圏': [1.6959, 47.7516],
    'オーヴェルニュ＝ローヌ＝アルプ地域圏': [4.7719, 45.1692],
    'ブルゴーニュ＝フランシュ＝コンテ地域圏': [4.8994, 47.2792],
    'ヌーヴェル＝アキテーヌ地域圏': [-0.5714, 45.7085],
    'グラン・テスト地域圏': [7.2600, 47.7500],
    'ローヌ＝アルプ地域圏': [4.8400, 45.7500],

    // Georgia
    'カヘティ州': [45.6175, 41.8261],

    // Germany
    'ラインラント＝プファルツ州': [7.3090, 49.9842],
    'バイエルン州': [11.5761, 48.1374],

    // Israel
    '北部地区': [35.2433, 32.6996],

    // Italy
    'アブルッツォ州': [13.3984, 42.3512],
    'ピエモンテ州': [8.0210, 45.0703],
    'シチリア州': [14.0154, 37.5999],
    'エミリア＝ロマーニャ州': [11.3417, 44.4949],
    'ロンバルディア州': [9.1900, 45.4668],
    'フリウリ＝ヴェネツィア・ジュリア州': [13.2350, 46.1263],
    'ヴェネト州': [12.2035, 45.4408],
    'トスカーナ州': [11.2529, 43.7696],
    'プーリア州': [16.6000, 40.7928],

    // Japan
    '北海道': [142.7734, 43.3218],
    '山梨県': [138.5683, 35.6639],
    '長野県': [138.1803, 36.2380],
    '岩手県': [141.1527, 39.7036],
    '東京都': [139.6917, 35.6895],
    '埼玉県': [139.6489, 35.8575],

    // New Zealand
    'マールボロ地方': [173.9610, -41.6103],

    // Portugal
    'セントロ地方': [-7.8004, 39.5572],
    'ノルテ地方': [-8.6110, 41.1496],

    // South Africa
    '西ケープ州': [18.4232, -33.9249],

    // Spain
    'ガリシア州': [-7.8662, 42.7551],
    'ナバラ州': [-1.6500, 42.8169],
    'カタルーニャ州': [1.8677, 41.8205],
    'エストレマドゥーラ州': [-6.1667, 39.4860],
    'カスティーリャ＝ラ・マンチャ州': [-3.0000, 39.0000],
    'アラゴン州': [-0.7833, 41.6000],
    'リオハ州': [-2.5150, 42.2871],

    // USA
    'カリフォルニア州': [-119.4179, 36.7783],
};
```

# 表示実装
## 表示までに必要なこと

以下のことが必要になります。
- 飲んだワインのcsvデータの読み込み
- 国や地域ごとに飲んだワインの本数と色の割合を表示するためのドーナツチャートsvgの作成
    - ドーナツチャートsvgの作成方法は[Display HTML clusters with custom properties](https://docs.mapbox.com/mapbox-gl-js/example/cluster-html/)を参考にしました
- svgを表示するためのレイヤーの作成
- svg表示に使用するデータの作成
- zoomレベルに応じて国表示と地域表示の切り替え

すべて書くと長くなるので、zoomレベルに応じて国表示と地域表示の切り替えのみ記載します。

## zoomレベルによって国表示と地域表示の切り替え

まずは表示切り替え用のstateを用意します。
```typescript
const [visibilityOfCountry, setVisibilityOfCountry] = useState<boolean>(true);
const [visibilityOfRegion, setVisibilityOfRegion] = useState<boolean>(false);
```

次に国と地域用のlayerを用意します。
```typescript
const layers = [
    new IconLayer({
        id: "donut-chart-layer-for-country",
        data: wineByCountry,
        getPosition: (d: WineByCountry) => d.coordinate,
        getIcon: (d: WineByCountry) => ({
            url: svgToDataURL(createDonutChartSVG(d)),
            width: getSizeBasedOnWineCount(d),
            height: getSizeBasedOnWineCount(d),
        }),
        getSize: (d: WineByCountry) => getSizeBasedOnWineCount(d),
        sizeScale: 0.65,
        visible: visibilityOfCountry,
    }),
    new IconLayer({
        id: "donut-chart-layer-for-region",
        data: wineByRegion,
        getPosition: (d: WineByRegion) => d.coordinate,
        getIcon: (d: WineByRegion) => ({
            url: svgToDataURL(createDonutChartSVG(d)),
            width: getSizeBasedOnWineCountInRegion(d),
            height: getSizeBasedOnWineCountInRegion(d),
        }),
        getSize: (d: WineByRegion) => getSizeBasedOnWineCountInRegion(d),
        sizeScale: 0.65,
        visible: visibilityOfRegion,
    }),
];
```

最後にdeck.glのonViewStateChangeに切り替え条件を記載します。
今回はzoomレベルが3.75以上の場合は地域ごとにクラスタリングを表示するようにしました。
```typescript
<DeckGL
    layers={layers}
    initialViewState={initialState}
    controller={{inertia: true}}
    views={new MapView({repeat: true})}
    onViewStateChange={({viewState}) => {
    // ここで切り替える
        if (viewState.zoom > 3.75) {
            setVisibilityOfCountry(false);
            setVisibilityOfRegion(true);
        } else {
            setVisibilityOfCountry(true);
            setVisibilityOfRegion(false);
        }
    }}
>
    <Map
        mapStyle="mapbox://styles/mapbox/dark-v10"
        style={{width: '100vw', height: '100vh'}}
        mapboxAccessToken={MAPBOX_ACCESS_TOKEN}
    />
</DeckGL>
```

# ボツ案
最初は[mapbox/supercluster](https://github.com/mapbox/supercluster)というライブラリを使って同様のことを実装していました。ただし、クラスタリングする対象範囲を絞るのが難しいことと、クラスタリング後の表示位置を調整するのが難しかったので、今回はちょっと泥臭いですがonViewStateChange内でzoomレベルによって国と地域のクラスタ表示を切り替えるという方法を採用することにしました。

# 終わりに
最初はせっかく飲んだワインをちゃんと覚えたいと思いメモを取っていましたが、まさかこんな形で役に立つとは思いませんでした！
特になにかに活かせる感じはないなーと思いましたが、表示することでとても満足することができました！
これからも改良を続けていきたいと思います！