コピペでOK!アメダスのデータをそれっぽく可視化してみよう
2021年12月17日 金曜日
CONTENTS
【IIJ 2021 TECHアドベントカレンダー 12/17(金)の記事です】
はじめに
またまた名古屋からこんにちは。今年もアドベントカレンダーへエントリーしてみました。
やっと2021年も本気出していこうと思っていたところ、もう終わりなんですね。残念、来年から本気出します。
まあ終わり良ければ総て良しと言うことで、最後はこれで締めたいと思います。最後までよろしくお願いします。
テーマを決める
とりあえず勢いでエントリーしてみたものの、何をテーマにしようかなーと考えていたところ、コロナ感染者マップが目に入ってきました。
“地図楽しそうだなー。地図かぁ・・・そうだ!!オープンデータを可視化しよう!”
うん、ビビッと来たわけです。
でもコロナ関係は巷にあふれているから別の何かにしようとググっていたところ、こんな記事がヒットしました。
ちょっとグレーなAPI感が漂ったんですが、気象庁の @e_toyoda さんのtwitterを引用すると、政府標準利用規約に準拠していれば使っても良さげと言うことがわかりました。
一番伸びてるのはこれかしら。仕様の継続性や運用状況のお知らせを気象庁はお約束していないという意味で、APIではないと申し上げざるを得ないのですが、一方で政府標準利用規約に準拠してご利用いただけます。
これはチャンスですね。早速使ってみることにしましょう。
ということで、今回のテーマは決まりました!
”アメダスのデータを使ってそれっぽく可視化してみよう”
作ってみよう
方針
まずは今回の作成方針です。
- お手軽
- コピペで感動が味わえる
複雑なフレームワークは使わず、最低限のライブラリのみで作り上げます。
またお手軽で且つコピペでOKと言うことで、アプリケーションサーバ無しで体験してもらいたいく、男らしく必要なファイルはindex.htmlのみで実現します。
完成イメージはこんな感じです。
アメダスデータ
今回の最も重要なデータとなるアメダスについてです。
アメダスのデータを使うにあたって必要なデータはアメダス観測所の一覧と気象情報データです。
アメダス観測所の一覧からは、観測所の位置情報と観測地の名称が取得できます。
気象情報データからは、気温・降水量・風向・風速・日照時間・積雪の深さ・湿度が取得できます。
アメダス観測所
アメダスの詳細は「地域気象観測システム(アメダス)」を参照して見てください。
今回は上記のHPから一覧をダウンロードせず、例のAPIから取得してみたいと思います。
軽く調べて見たところ、どうやら以下APIで取得できそうです。
- https://www.jma.go.jp/bosai/amedas/const/amedastable.json
取得してみると以下のようなデータが約1300箇所取得出来ます。
{ "51331": { "type": "C", "elems": "11112010", "lat": [34, 45.0], "lon": [137, 20.5], "alt": 3, "kjName": "豊橋", "knName": "トヨハシ", "enName": "Toyohashi" } }
簡単に解説すると、keyが観測所ID、latlonが緯度経度、altが高さ、~Nameが観測所の名称ですね。
気象情報データ
気象情報のCSVファイルは「「最新の気象データ」CSVダウンロードについて」を参照すれば情報が入手できますが、今回はAPIで取得したいので本HPは参考までとします。
肝心なAPIですが、こちらも軽く調べて見たところ、どうやら以下APIで取得できそなことがわかりました。
- https://www.jma.go.jp/bosai/amedas/data/map/20211212200000.json
太文字箇所は「YYYYMMDDHHMMSS」ですね。ただし、1時間毎の更新のようなので「MMSS」は「0000」としたほうが良さそうです。
取得してみると以下のようなデータが観測所の数だけ取得出来ます。
{ "51331": { "temp": [13.7, 0], "humidity": [71, 0], "snow1h": [0, null], "snow6h": [0, null], "snow12h": [0, null], "snow24h": [0, null], "sun10m": [0, 0], "sun1h": [0.0, 0], "precipitation10m": [0.0, 0], "precipitation1h": [0.0, 0], "precipitation3h": [0.0, 0], "precipitation24h": [0.0, 0], "windDirection": [13, 0], "wind": [4.0, 0] } }
様々なデータがありますが、一番扱いやすそうな気温を使ってみたいと思います。
keyが観測所ID、tempが気温ですね。しかし気温は配列となっており、最初の要素しか値が入っていません。ちょっと詳細が分からないため最初の要素を使ってみることにしましょう。
観測所と気象情報を組み合わせる
共に観測所IDがkey となりますので、観測所IDでjoinしましょう。
アメダス気象情報の気温はまれに設定されていない場合があるので、今回はN/Aとして扱います。
可視化する
せっかく座標があるので地図にプロットして可視化しましょう。パッと日本を表示した時に一目でわかる感じを目指します。
観測地点のポリゴン化
アメダス観測所の地点に気温を表示するのはどこにもあります。せっかくならあまり見たことのない見せ方をしたいと思います。
そこでちょっと考えました。
1300点の表現方法・・・点をプロットする?、線を引いてみる?、面で表現する?・・・面がよさそうだけどどうやって・・・そうだ!ボロノイ分割だ!
ボロノイ図(ボロノイず、英: Voronoi diagram)は、ある距離空間上の任意の位置に配置された複数個の点(母点)に対して、同一距離空間上の他の点がどの母点に近いかによって領域分けされた図のことである。特に二次元ユークリッド平面の場合、領域の境界線は、各々の母点の二等分線の一部になる。母点の位置のみによって分割パターンが決定されるため、母点に規則性を持たせれば美しい図形を生み出すことが可能。
出典:「ボロノイ図」『ウィキペディア フリー百科事典日本語版』
最終更新日時:2021年7月8日 08:05 (UTC)
ボロノイ分割してポリゴンを作って色を塗れば面白そうです。GIS業界ではコンビニなどの店舗を空間的に分析する時などで使っていますので今回の用途にぴったりです。
ライブラリ
地図ライブラリは昨年のアドベントカレンダーでも使用したみんな大好きLeafletを使います。
Leafletは軽量で使いやすく昔からお世話になっているんですが、最終更新が2020年9月4日(1.7.1)となっており、完全に下火になっている感じがしますが使い慣れたもので行きましょう。
最近地図から離れているので最新動向が追えていないのですが、最近はどんな地図ライブラリを使うのがトレンドなんでしょうね?2D系ではなく3D系かなと思いますが、誰か詳しい人教えてください!
あと、GISライブラリとして turf.js を使います。
幾何学計算やGeoJSONに関する処理が容易に行うことが出来ます。ボロノイ分割もこのturf.jsが活躍します。
工夫ポイント(見栄え向上)
ボロノイ図は図1のアメダス観測所の地点を使って作成します。
作成するときは観測所のバウンディングボックスを使用します。すると図2のようになるのですが、海域が広くなりすぎてちょっと見栄えがあまりよくありません。
陸地のみでクリッピングすると見栄えが良くなりそうなので、実現方法を考えてみます。
まず、陸地ポリゴンが必要になりますがどこで入手すればよいでしょうか?別途、国土数値情報の行政区域データなどを使えばキレイな陸地ポリゴンが作れそうですが、今回はお手軽にということで、これ以上ソースとなるデータを増やしたくありません。
今持っているアメダス観測所から陸地ポリゴンを作って、図2のボロノイ図をクリッピングする方針とします。
作り方は以下の手順で行います。
- 観測所の地点データを膨らませて図3の観測所ポリゴンを生成します
- ポリゴンの生成には turf.jsのbufferを使用します
- 重なり合った観測所ポリゴンを結合して、図4のように外側の辺だけでポリゴンを生成します
- 結合はturf.jsのdissolveを使用します
- 結合処理はそれなりに時間がかかります。今回はあえてリアルタイムで処理していますが、サービスなどで使うのであれば事前に処理したデータを使うとよいと思います
- 図2のボロノイ図と図4の結合ポリゴンとの論理積を求めてボロノイ図をクリッピングします
- クリッピングはturf.jsのintersectを使用します
このように、ちょっと工夫すれば見栄えがグッと良くなりました。
あとは気温で色を変化させれば感動を与える絵になりそうです!配色は、気象庁ホームページが定めた気象情報の配色を参考にすると一般受けしやすそうですね。
成果物
ソースコード
以下ソースはコピペしてindex.htmlとして保存してください。
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0" /> <title> visualizing amedas</title> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A==" crossorigin="" /> <script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js" integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA==" crossorigin=""></script> <script src='https://unpkg.com/@turf/turf@6/turf.min.js'></script> </head> <body> <div id="mapid" style="position:absolute;top:0;left:0;bottom:0;right:0;"></div> <script> (async function() { // 気温カラースケール定義 const temparatureColorScale = [ { temp: 35, color: '#B40068' }, { temp: 30, color: '#FF2800' }, { temp: 25, color: '#FF9900' }, { temp: 20, color: '#FFF500' }, { temp: 15, color: '#FFFF96' }, { temp: 10, color: '#FFFFF0' }, { temp: 5, color: '#B9EBFF' }, { temp: 0, color: '#0096FF' }, { temp: -5, color: '#0041FF' }, { temp: -273.15, color: '#002080' } ] // 気温カラーの取得 const getTemparatureColor = (temp) => { const colorScale = temparatureColorScale.find(item => { return temp > item.temp }) if( !colorScale ) return {temp: 'N/A', color: '#000000'} return colorScale } // アメダスデータの読み取り const fetchAmedasThenGenerateVoronoi = async (option) => { // アメダス観測所データの読み取り const fetchAmedasObservatories = async () => { const url = "https://www.jma.go.jp/bosai/amedas/const/amedastable.json" const amedasObservatories = {} const res = await fetch(url) const json = await res.json() Object.keys(json).map( key => { const amedas = json[key] json[key].lat = amedas.lat[0] + (amedas.lat[1] / 60) json[key].lon = amedas.lon[0] + (amedas.lon[1] / 60) }) return json } // アメダス気象情報データの読み取り const fetchAmedasWeatherInformations = async (queryDatetime) => { const url = "https://www.jma.go.jp/bosai/amedas/data/map/" + queryDatetime + ".json" const res = await fetch(url) const json = await res.json() return json } // アメダス観測所からボロノイポリゴンを作成 const generateGeoJsonAmedasVoronoiPolygons = async (amedas, option) => { const points = [] Object.keys(amedas).map( key => { const item = amedas[key] points.push(turf.point([item.lon, item.lat],{'key': key, 'kjName': item.kjName, 'knName': item.knName, 'alt': item.alt})) }) const collection = turf.featureCollection(points) const voronoiPolygons = turf.voronoi(collection, {bbox: turf.bbox(turf.buffer(collection, 50, {}))}) datetime = option.queryDatetime.match( /(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/ ) // アメダス観測所ポイントとボロノイポリゴンの内外判定を行い、観測所データをポリゴンへ移植 points.map( point => { voronoiPolygons.features.some( polygon => { if( turf.booleanPointInPolygon(point, polygon) === true ){ polygon.properties.key = point.properties.key polygon.properties.kjName = point.properties.kjName polygon.properties.knName = point.properties.knName polygon.properties.alt = point.properties.alt polygon.properties.lon = point.geometry.coordinates[0] polygon.properties.lat = point.geometry.coordinates[1] polygon.properties.queryDatetime = datetime[1] + '-' + datetime[2] + '-' + datetime[3] + ' ' + datetime[4] + ':' + datetime[5] + ':' + datetime[6] return true } return false }) }) // ボロノイ図を陸地でクリッピング if( option.isClipping === true) { const dissolvePolygons = turf.dissolve(turf.buffer(collection, 30, {steps: 2})) voronoiPolygons.features.map( voronoiPolygon => { dissolvePolygons.features.some( dissolvePolygon => { const intersectPolygon = turf.intersect(voronoiPolygon, dissolvePolygon) if (!intersectPolygon){ return false } voronoiPolygon.geometry = intersectPolygon.geometry return true }) }) } return voronoiPolygons } // 現在日時からアメダス気象情報取得パラメータを生成 const generateDateimeString = () => { const nowTime = new Date() nowTime.setHours((new Date()).getHours() -1) //nowTime.setMonth((new Date()).getMonth() -4) return nowTime.getFullYear().toString() + (nowTime.getMonth() + 1).toString().padStart(2, '0') + nowTime.getDate().toString().padStart(2, '0') + nowTime.getHours().toString().padStart(2, '0') + "0000" } const queryDatetime = generateDateimeString() const amedasObservatories = await fetchAmedasObservatories() const amedasWeatherInfos = await fetchAmedasWeatherInformations(queryDatetime) const voronoiPolygons = await generateGeoJsonAmedasVoronoiPolygons(amedasObservatories, {isClipping: option.isClipping, queryDatetime: queryDatetime}) return {observatories: amedasObservatories, weatherInfos: amedasWeatherInfos, voronoiPolygons: voronoiPolygons, queryDatetime: queryDatetime } } // Leaflet地図初期化 const initializeMainMap = () => { const map = L.map("mapid", L.extend({ zoom: 18, minZoom: 3, center: [35.17097, 136.88435], worldCopyJump: "true" })) map.setView([35.17097, 136.88435], 13) // スケールの追加 L.control.scale({ imperial: false, metric: true }).addTo(map) const controlLayers = L.control.layers({}, {},{collapsed: false}).addTo(map) return { map: map, controlLayers: controlLayers } } // ベースマップ const initializeBaseMaps = (mainMap) => { const map = mainMap.map const controlLayers = mainMap.controlLayers // ベース地図の追加 const osmDarkLayer = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { attribution: 'Map tiles by Carto, under CC BY 3.0. Data by OpenStreetMap, under ODbL.', maxZoom: 22, maxNativeZoom: 18 }).addTo(map) const osmLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>', maxZoom: 22, maxNativeZoom: 18 }) const gsiStdLayer = L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png', { attribution: '<a href="https://maps.gsi.go.jp/development/ichiran.html" target="_blank">地理院タイル</a>', maxZoom: 22, maxNativeZoom: 18 }) const gsiOrtLayer = L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg', { attribution: '<a href="https://maps.gsi.go.jp/development/ichiran.html" target="_blank">地理院タイル</a>, Images on 世界衛星モザイク画像 obtained from site https://lpdaac.usgs.gov/data_access maintained by the NASA Land Processes Distributed Active Archive Center (LP DAAC), USGS/Earth Resources Observation and Science (EROS) Center, Sioux Falls, South Dakota, (Year). Source of image data product.', maxZoom: 22, maxNativeZoom: 8 }) const gsiblankLayer = L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/blank/{z}/{x}/{y}.png', { attribution: '<a href="https://maps.gsi.go.jp/development/ichiran.html" target="_blank">地理院タイル</a>', maxZoom: 22, maxNativeZoom: 14 }) const baseMaps = { 'OpenStreetMap(Dark)': osmDarkLayer, 'OpenStreetMap': osmLayer, '国土地理院(標準)': gsiStdLayer, '国土地理院(写真)': gsiOrtLayer, '国土地理院(白地図)': gsiblankLayer } // レイヤ切り替えコントール設定 Object.keys(baseMaps).map( key => { controlLayers.addBaseLayer(baseMaps[key], key) }) return baseMaps } // パネルウインドウ const initializePanels = (mainMap) => { const map = mainMap.map // 情報パネル const addInfoPanel = (lmap) => { const info = L.control() const div = L.DomUtil.create('div', 'info') info.onAdd = (map) => { return div } info.update = (props) => { div.innerHTML = '<h4>Temperature</h4>' + (props ? '<b>' + props.queryDatetime + '<br />' + props.kjName + '<br />' + props.knName + '</b><br />' + props.temp + ' ℃' : 'Hover over polygon' ) } info.addTo(lmap) return info } // 凡例パネル const addLegendPanel = (lmap) => { const legend = L.control({position: 'bottomright'}) legend.onAdd = (map) => { const div = L.DomUtil.create('div', 'info legend') temparatureColorScale.map( item => { div.innerHTML += '<i style="background:' + item.color + '"></i> ' + '> ' + item.temp + '<br>' }) div.innerHTML += '<i style="background:' + getTemparatureColor('N/A').color + ' opacity:0.0"></i> ' + 'N/A' return div } legend.addTo(lmap) return legend } const infoPanel = addInfoPanel(map) const legendPanel = addLegendPanel(map) return {info : infoPanel, legend: legendPanel} } // オーバーレイレイヤ const initializeOverlayMaps = (mainMap, amedas, infoPanel) => { const map = mainMap.map const controlLayers = mainMap.controlLayers // アメダス観測所レイヤ const amedasObservatoryLayer = L.featureGroup([], { attribution: '<a href="https://www.jma.go.jp/bosai/map.html#6/41.27/133.308/&elem=temp&contents=amedas&interval=60">気象庁「アメダス」を加工して作成</a>' }) .on('add', (event) => { Object.keys(amedas.observatories).map( key => { const amedasItem = amedas.observatories[key] L.circle([amedasItem.lat, amedasItem.lon], {radius: 200, weight: 2}).addTo(event.sourceTarget).bindPopup(amedasItem.kjName) }) }) .addTo(map) // アメダス気象情報(気温)レイヤ const amedasTemperatureLayer = L.geoJSON( amedas.voronoiPolygons, { attribution: '<a href="https://www.jma.go.jp/bosai/map.html#6/41.27/133.308/&elem=temp&contents=amedas&interval=60">気象庁「アメダス」を加工して作成</a>', onEachFeature: (feature, layer) => { layer.on({ add: (event) => { const layer = event.target const style = {'color': '#000000', 'weight': 0.5, 'opacity': 1, 'fillOpacity': 0.8 } const weather = amedas.weatherInfos[layer.feature.properties.key] if( weather && weather.temp ) { style.fillColor = getTemparatureColor(weather.temp[0]).color layer.feature.properties.temp = weather.temp[0] } else { style.fillOpacity = 0 style.fillColor = getTemparatureColor(30).color layer.feature.properties.temp = 'N/A' } layer.originalStyle = style layer.setStyle(style) }, mouseover: (event) => { const layer = event.target layer.setStyle({ weight: 5, color: '#666666' }) infoPanel.update(layer.feature.properties) if (!L.Browser.ie && !L.Browser.opera && !L.Browser.edge) { layer.bringToFront() } }, mouseout: (event) => { const layer = event.target layer.setStyle(layer.originalStyle) infoPanel.update() } }) } }) .addTo(map) const overlayMaps = { "アメダス観測所": amedasObservatoryLayer, "気温": amedasTemperatureLayer } // レイヤ切り替えコントール設定 Object.keys(overlayMaps).map( key => { controlLayers.addOverlay(overlayMaps[key], key) }) return overlayMaps } // メイン const main = async () => { const amedas = await fetchAmedasThenGenerateVoronoi({isClipping: true}) const mainMap = initializeMainMap() const panels = initializePanels(mainMap) const baseMaps = initializeBaseMaps(mainMap) const overlayMaps = initializeOverlayMaps(mainMap, amedas, panels.info) } main() }()) </script> <style> .info { padding: 6px 8px; font: 14px/16px Arial, Helvetica, sans-serif; background: white; background: rgba(255,255,255,0.8); box-shadow: 0 0 15px rgba(0,0,0,0.2); border-radius: 5px; } .info h4 { margin: 0 0 5px; color: #777777; } .legend { line-height: 18px; color: #555555; } .legend i { width: 18px; height: 18px; float: left; margin-right: 8px; opacity: 0.7; } </style> </body> </html>
解説
generateGeoJsonAmedasVoronoiPolygons()
アメダス観測所データとアメダス気象情報データをAPIから取得します。
取得したデータを元にボロノイ図も作成し陸地でクリッピングしたポリゴンを生成します。
initializeMainMap()
leafletを使う上での必要な前処理や初期化を行います。
initializePanels()
地図以外の情報を表示するレイヤパネル、情報パネル、凡例パネルを初期化し画面上に配置します。
情報パネルと凡例パネルはLeafletの公式チュートリアルを参考にしています。めちゃくちゃ有能な公式ページです。
initializeBaseMaps()
背景に表示するベースとなる地図を初期化し背景図として使用できるようにします。
initializeOverlayMaps()
読み込んだアメダス観測所データをオーバーレイレイヤとして初期化し使用できるようにします。
マウスオーバー時の観測所名と気温の表示や、色の変更もここで行っています。
main()
本アプリのメイン処理です。上記のメソッドを呼び出します。
お手軽に味わってもらおうという趣旨なので、例外処理やクラス設計など一切しておりませんが、これぐらいの行数で比較的簡単に可視化は実現できます。情報パネルなどを省けばもっと行数を減らすとは出来ますが、見栄え重視で強引にねじ込みました。
デモ
最後に
さて突貫工事で作ってみたものの、思ったよりいい感じに仕上がったのではないでしょうか。
オープンデータは探せば自治体などで手軽に入手できる時代になりつつあります。
まだまだ続くコロナ禍の中、ちょっと息抜きに何かを可視化してみたらどうでしょうか?
是非この時間を有意義につかって、いろいろ可視化しちゃいましょう!
最後に宣伝となりますが、私が所属している名古屋支社開発チームは受託でありながらスクラムでお客様のビジネス拡大に貢献したい!と活動しています。興味がありましたら是非ご連絡ください!
執筆者Twitter | @nk_tamago ※意見は個人のものです |