2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-05-26

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

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

こんな感じ

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

地図情報をビジュアライズするライブラリ

検討したのは

  • d3js
  • amCharts5

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

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

実装したもの

こういうUI

地理データの準備

staticな地理データを読み込んでいるので、amCharts5が対応しているGeoJSON形式で地理データを用意してあげます。下記は市区町村データ。都道府県のデータはここから拝借しました。

  1. ここから日本全国の市区町村の境界データをDL
  2. zipを展開してshpファイルをQGISで読み込む
  3. GeoJSONファイルでエクスポート

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

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

実装

GeoJSONファイルの配置

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

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

配置したGeoJSONファイルの読み込み

僕はNext.jsで実装しているのでgetStaticPropsで読み込みました。読み込めればなんでもOKです。

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} />
    </>
  );
}

描画・処理をしているコンポーネント

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が非対応)

参考ページ

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?