趣味の個人開発で、地図情報を使ったアプリケーションを作ってます。
その機能の一つとして、都道府県を選択したら市区町村を表示し、それぞれの市区町村ごとのあるデータを表示するデータの可視化をする、というものがあります。
こんな感じ
サンプルは、あらかじめ用意されているアメリカや、世界地図の地理データが使われているので、日本のデータを自分で用意して実装してみました。
地図情報をビジュアライズするライブラリ
検討したのは
- d3js
- amCharts5
ある程度地図の描画に特化していたamcharts5を採用しました。
双方とも直接DOMを弄るので、パフォーマンス的な懸念はありますが、そこは目を瞑ります。
実装したもの
こういうUI
直接DOMを弄らないといけないからReactっぽいコードではないけど、都道府県から市区町村までのドリルダウンできた pic.twitter.com/fiMz0yP4qn
— どんちゃん (@d0nchaaan) 2023年5月25日
地理データの準備
staticな地理データを読み込んでいるので、amCharts5が対応しているGeoJSON形式で地理データを用意してあげます。下記は市区町村データ。都道府県のデータはここから拝借しました。
具体的な方法はそんなに難しくないのでググってください
FeatureにはそれぞれIDを持たせるようにしてください
実装
GeoJSONファイルの配置
デフォルトだとGeoJSONファイルは拡張子が.geojson
ですが、中身はただのjsonですので、拡張子を.json
に変えます。
それをプロジェクトの適当なディレクトリに配置しておきます。
配置したGeoJSONファイルの読み込み
僕はNext.jsで実装しているのでgetStaticProps
で読み込みました。読み込めればなんでもOKです。
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が非対応)
参考ページ