前回はアメダスの観測値を表形式で表示しましたが、雨量や気温は分布を見る需要もあるかと思いますので、地図表示ページを作成してみます
apiのURL
前回と同じですが、
特定時刻の全アメダスの観測値のデータは https://www.jma.go.jp/bosai/amedas/data/map/{ymdhns}.json に、
{ymdhns}の部分は観測時刻(JST、10分単位。20260319161000 など)で、最新は https://www.jma.go.jp/bosai/amedas/data/latest_time.txt に、
アメダス番号と観測点名などの対応は https://www.jma.go.jp/bosai/amedas/const/amedastable.json に格納されています
現在時刻に近いデータを取得する際には、必ずあらかじめ最新の観測時刻を取得してから、それ以前の時刻のデータを取得するようにしてください。最新時刻を決め打ちで取得すると、データができていなかった際に404エラーが返り、その応答が中継サーバーのキャッシュに記録されることで他の利用者にも迷惑がかかるおそれがあります
apiの構造
前回の記事をご覧ください
表示用のコード
今回は、Leaflet.jsを利用して地図表示していきます。Leafletの詳細な利用法は、公式のチュートリアルとドキュメントをご覧ください
headタグ内でLeafletのスタイルシートとスクリプトを読み込みます(Leafletのチュートリアルからそのまま拝借)
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
ページのレイアウトを定義します。今回は html、bodyを画面幅いっぱいに表示し、flexで上下にメニューと地図部分を分けて表示します。地図上の地点名と数値を白抜き黒縁取りの太文字で表示する設定にして見やすくするよう定義します。縁取りは数値の色の濃さに応じて黒と白の2パターンを用意しておきます
<style>
*{ font-family:sans-serif;}
html, body{ width:100%; height:100%; margin:0; overflow:hidden;}
body{ display:flex; flex-direction:column;}
div#menu{ flex:0.01;}
div#map{ flex:0.99;}
.center{ text-align:center;}
.amedasName, .amedasVal{ font-weight:bold; color:white;}
.borderBlack{ text-shadow:1px 1px 0 #000, -1px -1px 0 #000, -1px 1px 0 #000, 1px -1px 0 #000;}
.borderWhite{ text-shadow:1px 1px 0 #fff, -1px -1px 0 #fff, -1px 1px 0 #fff, 1px -1px 0 #fff;}
</style>
地点や要素の切り替え時に使いまわせるよう、冒頭で地図を定義します。前回と同様、要素名と日本語表記、有効桁数を定義します。値に応じて色を変えられるよう、閾値と色も定義しておきます
let map = L.map('map').setView([35, 135], 8);
// 各要素の日本語名と小数点以下の有効桁数
const amedasElems = {
"temp":{"ja":"気温[℃]","digit":1},
"precipitation1h":{"ja":"1時間降水量[mm]","digit":1},
"windDirection":{"ja":"風向"}, "wind":{"ja":"風速[m/s]","digit":1},
"sun1h":{"ja":"1時間日照時間[h]","digit":1},
"snow":{"ja":"積雪[cm]","digit":0}, "snow1h":{"ja":"1時間降雪量[cm]","digit":0},
"humidity":{"ja":"湿度[%]","digit":0},
"normalPressure":{"ja":"気圧[hPa]","digit":1}, "visibility":{"ja":"視程[m]","digit":0}, "weather":{"ja":"天気"}
};
// [閾値(これ未満の時),色]
const amedasThresholds = {
"temp":[ [-25,"#d8d6e4"], [-20,"#a8a3c5"], [-15,"#776fa4"], [-10,"#4d4471"], [-5,"#002080"], [0,"#0041ff"], [5,"#0096ff"], [10,"#b9ebff"], [15,"#fffff0"], [20,"#ffff96"], [25,"#faf500"], [30,"#ff9900"], [35,"#ff2800"], [40,"#b40068"], [Infinity,"#50002e"]],
"precipitation1h":[ [1,"#f2f2ff"], [5,"#a0d2ff"], [10,"#218cff"], [20,"#0041ff"], [30,"#faf500"], [50,"#ff9900"], [80,"#ff2800"], [110,"#b40068"], [Infinity,"#50002e"]]
......
}
初期化時に、地図のレイヤーを定義します。今回は、気象庁ホームページのアメダスのページで利用されている地図と、国土地理院の淡色地図の2択としていますが、お好きな地図を追加することも可能です
L.control.layers({
"気象庁アメダス地図":L.tileLayer('https://www.jma.go.jp/tile/jma/green-cities/{z}/{x}/{y}.png', { minZoom:4, maxZoom:12, maxNativeZoom:10, attribution: '© <a href="https://www.jma.go.jp/jma/kishou/info/coment.html">気象庁</a>'}).addTo(map),
"地理院地図":L.tileLayer('https://maps.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png', { minZoom:2, maxZoom:18, attribution: '© <a href="https://maps.gsi.go.jp/development/ichiran.html">国土地理院</a>'})
}).addTo(map);
天気は数字で格納されているため、対応表を用意しておきます。表形式の際は「晴れ」「くもり」「雨」などとしていましたが、地図に表示する際は記号の方が一覧性が高いと思いますので、「○」「◎」「●」など日本式天気記号を意識した表現にします
const weathers = ["○","◎","∞","霧","P","霧雨","着氷性の霧雨","●","着氷性の雨","みぞれ","*","凍雨","霧雪","しゅう雨","しゅう雪","▲","雷"];
地点名と数値を表示するレイヤーグループを定義します(その際に別の要素を表示していた場合には除去します)
try{ map.removeLayer(layers['val']);}catch(e){};
layers['val'] = L.layerGroup();
アメダスの各地点ごとに、選択された要素が存在するかを調べ、存在する場合は値に応じて表示色を判定します。表示色が一定の濃さ以上なら縁取りは白に、そうでなければ黒にします。
if( amedas[amedasCode]==undefined || amedas[amedasCode][targetElem]==undefined){
continue;
}
let val = amedas[amedasCode][targetElem][0], qcFlag = amedas[amedasCode][targetElem][1], valColor = "#ffffff", valBorder = "borderBlack";
if( val!=null && amedasThresholds[targetElem]!=undefined){
for( let threshold of amedasThresholds[targetElem]){
if( val < threshold[0]){
valColor = threshold[1];
if( parseInt(valColor.substring(1,3),16) + parseInt(valColor.substring(3,5),16) + parseInt(valColor.substring(5,7),16) < 408){
valBorder = "borderWhite";
}else{
valBorder = "borderBlack";
}
break;
}
}
}
数値+改行+地点名 のアイコンを作成します。選択要素が風の場合は、風の矢羽根アイコン+改行+数値+改行+地点名 のアイコンを作成します
矢羽根アイコンは風向に応じて回転します。風向は16方位で 1 北北東、2 北東、3 東北東、……(以下時計回り)……、16 北 の順になっているため、南向きのアイコンを用意し、rotateで風向の数値×22.5°回転させることで表現できます。風向のアイコンは、風速に応じて7段階の色をプレゼンテーションソフトで適当に作りました
let valIcon = L.divIcon({ html:"<span style='font-size:14px; color:" + valColor + ";' class='" + valBorder + "'>" + val + "</span><br>"
+ "<span style='font-size:12px;' class='borderBlack'>"+amedasName+"</span>",
iconSize:[80,30], iconAnchor:[40,10], className:"amedasVal center"});
if( targetElem == "wind"){
let arrowLevel = 0, arrowRotate = 0, windSpeed = amedas[amedasCode]['wind'][0], windDir = amedas[amedasCode]['windDirection'][0];
if( !isNaN(windSpeed) && windDir!=0){
arrowLevel = Math.min( Math.floor(windSpeed/5)+1, 7);
arrowRotate = windDir * 22.5;
}
valIcon = L.divIcon({ html:"<img src='./wind/arrow" + arrowLevel + ".png' style='width:20px; transform:rotate(" + arrowRotate + "deg);' class='" + valBorder + "'><br>"
+ "<span style='color:" + valColor + "; font-size:14px;' class='" + valBorder + "'>" + val + "</span><br>"
+ "<span style='font-size:12px;' class='borderBlack'>"+amedasName+"</span>",
iconSize:[80,30], iconAnchor:[40,10], className:"amedasVal center"});
}
let valMarker = L.marker([amedasInfos[amedasCode]['lat'][0]+amedasInfos[amedasCode]['lat'][1]/60, amedasInfos[amedasCode]['lon'][0]+amedasInfos[amedasCode]['lon'][1]/60], {icon: valIcon});
layers['val'].addLayer( valMarker);
表示ページ全体のソースコード
サンプルページ
サンプルページ(Date APIを使用した版)
風向のアイコン
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>アメダス(地図)</title>
<!-- https://leafletjs.com/ -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<style>
*{ font-family:sans-serif;}
html, body{ width:100%; height:100%; margin:0; overflow:hidden;}
body{ display:flex; flex-direction:column;}
div#menu{ flex:0.01;}
div#map{ flex:0.99;}
.center{ text-align:center;}
.amedasName, .amedasVal{ font-weight:bold; color:white;}
.borderBlack{ text-shadow:1px 1px 0 #000, -1px -1px 0 #000, -1px 1px 0 #000, 1px -1px 0 #000;}
.borderWhite{ text-shadow:1px 1px 0 #fff, -1px -1px 0 #fff, -1px 1px 0 #fff, 1px -1px 0 #fff;}
</style>
</head>
<body>
<div id="menu">
<select id="elem"></select>
<input type="datetime-local" id="targetTime">
<button id="display">表示</button>
<button id="getLatestTime">最新</button>
</div>
<div id="map"></div>
<script>
"use strict";
let amedasInfos = {}, layers = {};
let map = L.map('map').setView([35, 135], 8);
// 各要素の日本語名と小数点以下の有効桁数
const amedasElems = {
"temp":{"ja":"気温[℃]","digit":1},
"precipitation1h":{"ja":"1時間降水量[mm]","digit":1},
"windDirection":{"ja":"風向"}, "wind":{"ja":"風速[m/s]","digit":1},
"sun1h":{"ja":"1時間日照時間[h]","digit":1},
"snow":{"ja":"積雪[cm]","digit":0}, "snow1h":{"ja":"1時間降雪量[cm]","digit":0},
"humidity":{"ja":"湿度[%]","digit":0},
"normalPressure":{"ja":"気圧[hPa]","digit":1}, "visibility":{"ja":"視程[m]","digit":0}, "weather":{"ja":"天気"}
};
// [閾値(これ未満の時),色]
const amedasThresholds = {
"temp":[ [-25,"#d8d6e4"], [-20,"#a8a3c5"], [-15,"#776fa4"], [-10,"#4d4471"], [-5,"#002080"], [0,"#0041ff"], [5,"#0096ff"], [10,"#b9ebff"], [15,"#fffff0"], [20,"#ffff96"], [25,"#faf500"], [30,"#ff9900"], [35,"#ff2800"], [40,"#b40068"], [Infinity,"#50002e"]],
"precipitation1h":[ [1,"#f2f2ff"], [5,"#a0d2ff"], [10,"#218cff"], [20,"#0041ff"], [30,"#faf500"], [50,"#ff9900"], [80,"#ff2800"], [110,"#b40068"], [Infinity,"#50002e"]],
"wind":[ [5,"#f2f2ff"], [10,"#0041ff"], [15,"#faf500"], [20,"#ff9900"], [25,"#ff2800"], [35,"#b40068"], [Infinity,"#50002e"]],
"sun1h":[ [0.2,"#0041ff"], [0.4,"#a0d2ff"], [0.6,"#ffff96"], [0.8,"#faf500"], [1,"#ff9900"], [Infinity,"#ff2800"]],
"snow":[ [1,"#f2f2ff"], [5,"#a0d2ff"], [20,"#218cff"], [50,"#0041ff"], [100,"#faf500"], [150,"#ff9900"], [200,"#ff2800"], [300,"#b40068"], [Infinity,"#50002e"]],
"snow1h":[ [1,"#f2f2ff"], [3,"#a0d2ff"], [5,"#218cff"], [8,"#0041ff"], [10,"#faf500"], [15,"#ff9900"], [20,"#ff2800"], [25,"#b40068"], [Infinity,"#50002e"]],
"humidity":[ [10,"#540600"], [20,"#761100"], [30,"#ab4a01"], [40,"#e78707"], [50,"#ffc846"], [60,"#fffff0"], [70,"#80f8e7"], [80,"#1fc2d3"], [90,"#00729a"], [100,"#004b96"], [Infinity,"#011f7d"]],
"visibility":[ [300,"#b40068"], [1000,"#ff2800"], [2000,"#ff9900"], [5000,"#faf500"], [10000,"#b9ebff"], [20000,"#0096ff"], [Infinity,"#0041ff"]],
"normalPressure":[ [940,"#50002e"], [950,"#b40068"], [960,"#ff2800"], [970,"#ff9900"], [980,"#faf500"], [990,"#ffff96"], [1000,"#fffff0"], [1010,"#b9ebff"], [1020,"#0096ff"], [1030,"#0041ff"], [Infinity,"#002080"]],
"weather":[ [1,"#ffa900"],[2,"#a9a9a9"],[4,"#b40068"],[8,"#0041ff"],[10,"#00aaff"],[13,"#f2f2ff"],[14,"#0041ff"],[15,"#00aaff"],[17,"#ff2800"],[Infinity,"#ffffff"]],
}
initialize();
// 要素選択欄の作成、地図の表示、アメダス定数ファイルの取得
function initialize(){
let elemSelect = "";
for( let elemName in amedasElems){
if( elemName!="windDirection"){
elemSelect += "<option value='" + elemName + "'>" + amedasElems[elemName]['ja'] + "</option>";
}
}
document.getElementById("elem").innerHTML = elemSelect;
L.control.layers({
"気象庁アメダス地図":L.tileLayer('https://www.jma.go.jp/tile/jma/green-cities/{z}/{x}/{y}.png', { minZoom:4, maxZoom:12, maxNativeZoom:10, attribution: '© <a href="https://www.jma.go.jp/jma/kishou/info/coment.html">気象庁</a>'}).addTo(map),
"地理院地図":L.tileLayer('https://maps.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png', { minZoom:2, maxZoom:18, attribution: '© <a href="https://maps.gsi.go.jp/development/ichiran.html">国土地理院</a>'})
}).addTo(map);
fetch("https://www.jma.go.jp/bosai/amedas/const/amedastable.json")
.then((response) => response.json())
.then((response) => {
amedasInfos = response;
getLatestTime();
});
}
document.getElementById("getLatestTime").addEventListener("click",function(e){
getLatestTime();
});
// 入電している最新のアメダス時刻の取得
function getLatestTime(){
let instant = Temporal.Now.instant(); // URLパラメーターに時刻を付加して最新の時刻が取得できるようにする
fetch("https://www.jma.go.jp/bosai/amedas/data/latest_time.txt?__time__=" + instant.epochSeconds)
.then((response) => response.text())
.then((latestTimeText) => {
let latestTime = Temporal.ZonedDateTime.from( latestTimeText+"[Asia/Tokyo]");
document.getElementById("targetTime").value = dateFormat(latestTime,'y-m-dTh:n');
get();
});
}
document.getElementById("display").addEventListener("click",function(e){
get();
});
// アメダスデータの取得
function get(){
let targetTime = Temporal.ZonedDateTime.from( document.getElementById("targetTime").value + "+09:00[Asia/Tokyo]");
fetch("https://www.jma.go.jp/bosai/amedas/data/map/" + dateFormat(targetTime,'ymdhns') + ".json")
.then((response) => response.json())
.then((amedas) => {
display(amedas);
});
}
// アメダスデータの表示
function display(amedas){
let targetElem = document.getElementById("elem").value;
// 品質管理フラグの一覧と付加するマーク、上書きする文字
const qcFlags = { "0":{"status":"正常","mark":""}, "1":{"status":"準正常","mark":")"}, "2":{"status":"疑問値","mark":"#"}, "3":{"status":"利用不適","mark":"×"}, "4":{"status":"資料不足","mark":"]"}, "5":{"status":"計画欠測","overwrite":"-"}, "6":{"status":"未受信/障害欠測","overwrite":"×"}, "7":{"status":"未実施","overwrite":""}};
// 天気は数字で示されるので対応表を定義
const weathers = ["○","◎","∞","霧","P","霧雨","着氷性の霧雨","●","着氷性の雨","みぞれ","*","凍雨","霧雪","しゅう雨","しゅう雪","▲","雷"];
try{ map.removeLayer(layers['val']);}catch(e){};
layers['val'] = L.layerGroup();
for( let amedasCode in amedasInfos){
let amedasName = amedasInfos[amedasCode]['kjName'];
if( amedas[amedasCode]==undefined || amedas[amedasCode][targetElem]==undefined){
continue;
}
let val = amedas[amedasCode][targetElem][0], qcFlag = amedas[amedasCode][targetElem][1], valColor = "#ffffff", valBorder = "borderBlack";
if( val!=null && amedasThresholds[targetElem]!=undefined){
for( let threshold of amedasThresholds[targetElem]){
if( val < threshold[0]){
valColor = threshold[1];
if( parseInt(valColor.substring(1,3),16) + parseInt(valColor.substring(3,5),16) + parseInt(valColor.substring(5,7),16) < 408){
valBorder = "borderWhite";
}else{
valBorder = "borderBlack";
}
break;
}
}
}
if( val!=null && amedasElems[targetElem]['digit']!=undefined){
val = val.toFixed( amedasElems[targetElem]['digit']); // 要素ごとに有効桁数に四捨五入
}
if( targetElem=="weather"){
val = weathers[val];
}
if( qcFlags[qcFlag]['overwrite']!=undefined){ // 障害欠測など重大な異常値は記号で上書き
val = qcFlags[qcFlag]['overwrite'];
}else if( qcFlags[qcFlag]['mark']!=undefined){ // 準正常値など軽微な異常値は記号を追加
val += qcFlags[qcFlag]['mark'];
}
let valIcon = L.divIcon({ html:"<span style='font-size:14px; color:" + valColor + ";' class='" + valBorder + "'>" + val + "</span><br>"
+ "<span style='font-size:12px;' class='borderBlack'>"+amedasName+"</span>",
iconSize:[80,30], iconAnchor:[40,10], className:"amedasVal center"});
if( targetElem == "wind"){
let arrowLevel = 0, arrowRotate = 0, windSpeed = amedas[amedasCode]['wind'][0], windDir = amedas[amedasCode]['windDirection'][0];
if( !isNaN(windSpeed) && windDir!=0){
arrowLevel = Math.min( Math.floor(windSpeed/5)+1, 7);
arrowRotate = windDir * 22.5;
}
valIcon = L.divIcon({ html:"<img src='./wind/arrow" + arrowLevel + ".png' style='width:20px; transform:rotate(" + arrowRotate + "deg);' class='" + valBorder + "'><br>"
+ "<span style='color:" + valColor + "; font-size:14px;' class='" + valBorder + "'>" + val + "</span><br>"
+ "<span style='font-size:12px;' class='borderBlack'>"+amedasName+"</span>",
iconSize:[80,30], iconAnchor:[40,10], className:"amedasVal center"});
}
let valMarker = L.marker([amedasInfos[amedasCode]['lat'][0]+amedasInfos[amedasCode]['lat'][1]/60, amedasInfos[amedasCode]['lon'][0]+amedasInfos[amedasCode]['lon'][1]/60], {icon: valIcon});
layers['val'].addLayer( valMarker);
}
map.addLayer( layers['val']);
}
// Temporal.ZonedDateTime を文字列形式に変換
function dateFormat( t, f='y-m-dTh:n:s', is24=false){
const j=["","月","火","水","木","金","土","日"];const e=["","Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
if(is24&&t.hour==0){t.substract({days:1});f=f.replace(/h/g,"24").replace(/H/g,24);}
const y=t.year,m=t.month,d=t.day,h=t.hour,n=t.minute,s=t.second,w=t.dayOfWeek;
f=f.replace(/y/g,("000"+y).slice(-4)).replace(/Y/g,y).replace(/m/g,("0"+m).slice(-2)).replace(/M/g,m).replace(/d/g,("0"+d).slice(-2)).replace(/D/g,d);
f=f.replace(/w/g,j[w]).replace(/W/g,e[w]);
f=f.replace(/h/g,("0"+h).slice(-2)).replace(/H/g,h).replace(/n/g,("0"+n).slice(-2)).replace(/N/g,n).replace(/s/g,("0"+s).slice(-2)).replace(/S/g,s);
return f;
}
</script>
</body>
</html>