ReactとamCharts5で地図の描画とドリルダウンなUIを実装してみる

趣味の個人開発で、地図情報を使ったアプリケーションを作ってます。

その機能の一つとして、都道府県を選択したら市区町村を表示し、それぞれの市区町村ごとのあるデータを表示するデータの可視化をする、というものがあります。

こんな感じ

https://www.amcharts.com/demos/drill-down-to-countries/

サンプルは、あらかじめ用意されているアメリカや、世界地図の地理データが使われているので、日本のデータを自分で用意して実装してみました。

## 地図情報をビジュアライズするライブラリ
検討したのは

- d3js
- amCharts5

ある程度地図の描画に特化していたamcharts5を採用しました。

双方とも直接DOMを弄るので、パフォーマンス的な懸念はありますが、そこは目を瞑ります。

## 実装したもの
こういうUI
<blockquote class="twitter-tweet" data-conversation="none" data-lang="ja" data-theme="light"><p lang="ja" dir="ltr">直接DOMを弄らないといけないからReactっぽいコードではないけど、都道府県から市区町村までのドリルダウンできた <a href="https://t.co/fiMz0yP4qn">pic.twitter.com/fiMz0yP4qn</a></p>&mdash; どんちゃん (@d0nchaaan) <a href="https://twitter.com/d0nchaaan/status/1661757862748770304?ref_src=twsrc%5Etfw">2023年5月25日</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 地理データの準備
staticな地理データを読み込んでいるので、amCharts5が対応しているGeoJSON形式で地理データを用意してあげます。下記は市区町村データ。都道府県のデータは[ここから拝借](https://japonyol.net/editor/article/47-prefectures-geojson.html)しました。

https://www.esrij.com/products/japan-shp/

1. [ここ](https://www.esrij.com/products/japan-shp/)から日本全国の市区町村の境界データをDL
2. zipを展開してshpファイルを[QGIS](https://qgis.org/ja/site/)で読み込む
3. GeoJSONファイルでエクスポート

具体的な方法はそんなに難しくないのでググってください

> FeatureにはそれぞれIDを持たせるようにしてください

https://zenn.dev/yuichiyazaki/articles/9d60d3eb584e54

## 実装
### GeoJSONファイルの配置
デフォルトだとGeoJSONファイルは拡張子が`.geojson`ですが、中身はただのjsonですので、拡張子を`.json`に変えます。

それをプロジェクトの適当なディレクトリに配置しておきます。

### 配置したGeoJSONファイルの読み込み
僕はNext.jsで実装しているので`getStaticProps`で読み込みました。読み込めればなんでもOKです。

```tsx:pages/index.tsx
import { promises as fs } from 'fs';
import path from 'path';

import { CityMap } from '@/components/CityMap';
export async function getStaticProps() {
  const jsonDirectory = path.join(process.cwd(), 'public');
  const jpCities = await fs.readFile(jsonDirectory + '/jp_cities.json', 'utf8');
  const jpPrefectures = await fs.readFile(jsonDirectory + '/prefectures.json', 'utf8');

  return {
    props: {
      jpCities: JSON.parse(jpCities),
      jpPrefectures: JSON.parse(jpPrefectures),
    },
  };
}

export default function Home(props) {
  return (
    <>
      <CityMap {...props} />
    </>
  );
}

```

### 描画・処理をしているコンポーネント
```tsx
import { useLayoutEffect, useRef } from 'react';

import * as am5 from '@amcharts/amcharts5';
import * as am5map from '@amcharts/amcharts5/map';
import am5themes_Animated from '@amcharts/amcharts5/themes/Animated';

import { GeoJsonDataPrefecture } from '@/components/CityMap/prefecture.type';

export const CityMap = ({ jpCities, jpPrefectures }) => {
  const ref = useRef(null);
  useLayoutEffect(() => {
    if (!ref.current) return;
    // 描画領域の要素を取得
    const root = am5.Root.new(ref.current);
    // テーマを設定
    const colors = am5.ColorSet.new(root, {});
    root.setThemes([am5themes_Animated.new(root)]);

    // 地図の設定
    const chart = root.container.children.push(
      am5map.MapChart.new(root, {
        panX: 'rotateX',
        projection: am5map.geoMercator(),
      }),
    );
    // 都道府県レベルマップ
    const jpPrefectureSeries = chart.series.push(
      am5map.MapPolygonSeries.new(root, {
        geoJSON: jpPrefectures,
      }),
    );
    jpPrefectureSeries.mapPolygons.template.setAll({
      tooltipText: '{name}',
      interactive: true,
      fill: am5.color(0xaaaaaa),
      templateField: 'polygonSettings',
    });
    jpPrefectureSeries.mapPolygons.template.states.create('hover', {
      fill: colors.getIndex(9),
    });
    jpPrefectureSeries.mapPolygons.template.events.on('click', async function (ev) {
      const dataItem = ev.target.dataItem;
      const data = dataItem?.dataContext as GeoJsonDataPrefecture['features'][0]['properties'];
      jpPrefectureSeries.zoomToDataItem(dataItem);
      const citiesInSelectedPrefecture = jpCities.features.filter((feature) => feature.properties.KEN === data.name);
      const jpCityGeoJSON = { ...jpCities, features: citiesInSelectedPrefecture };
      jpCitySeries.setAll({
        geoJSON: jpCityGeoJSON,
        fill: am5.color(0xaaaaaa),
      });

      jpPrefectureSeries.hide(150);
      jpCitySeries.show();
      backContainer.show();
    });
    // 市区町村レベルマップ
    const jpCitySeries = chart.series.push(
      am5map.MapPolygonSeries.new(root, {
        visible: false,
      }),
    );
    jpCitySeries.mapPolygons.template.setAll({
      tooltipText: '{SEIREI}{SIKUCHOSON}',
      interactive: true,
      fill: am5.color(0xaaaaaa),
    });
    jpCitySeries.mapPolygons.template.states.create('hover', {
      fill: colors.getIndex(9),
    });
    // 拡大率リセットボタン
    const backContainer = chart.children.push(
      am5.Container.new(root, {
        x: am5.p100,
        centerX: am5.p100,
        dx: -10,
        paddingTop: 5,
        paddingRight: 10,
        paddingBottom: 5,
        y: 30,
        interactiveChildren: false,
        layout: root.horizontalLayout,
        cursorOverStyle: 'pointer',
        background: am5.RoundedRectangle.new(root, {
          fill: am5.color(0xffffff),
          fillOpacity: 0.2,
        }),
        visible: false,
      }),
    );
    backContainer.children.push(
      am5.Label.new(root, {
        text: '戻る',
        centerY: am5.p50,
      }),
    );
    backContainer.events.on('click', function () {
      chart.goHome();
      jpPrefectureSeries.show();
      jpCitySeries.hide();
      backContainer.hide();
    });
    // ズームコントロール
    chart.set('zoomControl', am5map.ZoomControl.new(root, {}));
    return () => {
      root.dispose();
    };
  }, [jpCities, jpPrefectures]);

  return <div ref={ref} style={{ width: '100%', height: '750px' }}></div>;
};

```

今回はとりあえず描画だけしてみました。このあとは外部データを用いてデータを可視化していきます。

現状からパフォーマンス改善の方法があれば教えてください🥺
(topoJSONが使えたらファイルは軽くなりそうだがamChartsが非対応)

## 参考ページ
https://hiyuzawa.jp/archives/436

https://morioh.com/p/074a8e5098ba