以前に作成した雷観測状況の表表示ページに手を加え、選択した市町村の近くで雷があった場合に音声で知らせるページを作ってみます
気象庁の雷観測について・apiのURL・APIの構造
気象庁が行う雷観測については以前の記事をご覧ください
APIについては、他の情報と同様、ブラウザの開発者ツールでネットワークタブを開いた状態で気象庁ホームページにアクセスし、Fetch/XHRでフィルタするとデータを格納したファイルが見つかります。ドメイン制限は(現在のところは)かかっていないため、自由に取得できます
- 最新の観測時刻は https://www.jma.go.jp/bosai/jmatile/data/nowc/targetTimes_N3.json
- 各時刻の雷の観測状況は https://www.jma.go.jp/bosai/jmatile/data/nowc/{basetime}/none/{validtime}/surf/liden/data.geojson?id=liden
に格納されています
{basetime}、{validtime}はtargetTimes_N3.jsonから取得できます。この手順を飛ばして{basetime} と {validtime} を推測してデータを取得しないでください。気象庁ホームページの配信経路上のサーバーに404のキャッシュが残ってしまい、正規の利用者に影響が出る可能性があります
また、緯度経度を「○○市付近」のような形に変換するため、
- 国内の各地域とその包含関係 https://www.jma.go.jp/bosai/common/const/area.json
- 各市町村・府県の中心位置 https://www.jma.go.jp/bosai/common/const/xy.json
- 各市町村の東西南北の緯度経度 https://www.jma.go.jp/bosai/common/const/class20relm.json
の各ファイルも活用します
ここでいう「中心位置」は大体市町村の真ん中あたりの地点で、市役所・町村役場の位置とは異なりますので注意してください。おそらく気象庁サイト上では、「あなたの街の防災情報」等で市町村を選択された際に、どこを中心に地図を表示するかを計算するような場面で使用しているのではないかと思います
APIの構造に関しても以前の記事と同様ですので、そちらをご覧ください
表示用のコード
以前の記事の内容に加えて、以下の機能を追加します
- 自由に市町村を選択し、そこから発雷位置までの距離を表示する機能
- 発雷位置までの距離が一定以下なら強調表示・報知する機能
- 自動更新機能
市町村の選択機能を実装します。最初の定数データ取得時に都道府県の選択欄を作成し、都道府県の選択が変わった場合に市町村の選択欄を更新します
// 都道府県の選択肢を作成
function makeOfficeSelect(){
let officeSelect = "";
const officeCodes = Object.keys(globals['areas']['offices']).sort((a,b)=>{ return a-b;});
for( let officeCode of officeCodes){
const officeName = globals['areas']['offices'][officeCode]['name'];
officeSelect += "<option value='" + officeCode + "'>" + officeName + "</option>";
}
document.getElementById("office").innerHTML = officeSelect;
makeClass20Select();
}
// 都道府県の変更時は市町村等の選択肢を再作成
document.getElementById("office").addEventListener("change", function(e){
makeClass20Select();
})
// 市町村等の選択肢を作成
function makeClass20Select(){
let class20Select = "";
// 府県コードの選択状況を取得し、配下の1次細分区域・まとめた地域・市町村の順に走査。選択肢を作成
const officeCode = document.getElementById("office").value;
for( let class10Code of globals['areas']['offices'][officeCode]['children']){
for( let class15Code of globals['areas']['class10s'][class10Code]['children']){
for( let class20Code of globals['areas']['class15s'][class15Code]['children']){
const class20Name = globals['areas']['class20s'][class20Code]['name'];
class20Select += "<option value='" + class20Code + "'>" + class20Name + "</option>";
}
}
}
document.getElementById("class20").innerHTML = class20Select;
}
市町村から発雷位置までの方向・距離を計算する機能を追加します(以前の記事で作成した内容とほぼ同じ)。計算した距離が一定以下の場合は発雷データにフラグを立てるようにします
// 選択市町村からの距離を計算
const selectedClass20 = document.getElementById("class20").value, selectedClass20Name = globals['areas']['class20s'][selectedClass20]['name'];
let selectedLat = globals['areaCenters']['class20s'][selectedClass20][0], selectedLon = globals['areaCenters']['class20s'][selectedClass20][1];
let dist = distance( lat, lon, selectedLat, selectedLon), dir = degToDir(angle( lat, lon, selectedLat, selectedLon));
feature['properties']['fromSelectedClass20'] = selectedClass20Name + "の" + dir + " 約" + Math.round(dist/1000) + "km";
// 距離が一定以下の場合はフラグを立てる
if( dist/1000 <= document.getElementById("thresholdDistance").value){
feature['properties']['isNear'] = true;
}
表示部では発雷データにフラグが立っている場合は強調表示し、音声報知用のフラグを立てます
// 近い位置での発雷は強調表示
if( feature['properties']['isNear']){
out += "<strong>";
existNear = true;
}
out += feature['properties']['fromSelectedClass20'];
if( feature['properties']['isNear']){
out += "</strong>";
}
音声報知のフラグが立っており、かつ音声報知のチェックが入っている場合には音声を鳴らします。音声は適当な物が思い浮かばなかったので、NHKの竜巻注意情報の報知音を模した報知音をMuseScoreで適当に作成して使用しました(転載や利用は禁じませんが、NHKの放送と混同の可能性があるような場面での使用は控え、フリーの警告音源に差し替える等してください)
// 近い位置での発雷があった場合かつ音声報知にチェックが入っていれば音声を鳴らす
if( existNear && document.getElementById("sound").checked){
let warningSound = new Audio('./tornado.mp3');
warningSound.play();
}
自動更新機能を実装します。3分ごとに、自動更新のチェックが入っている場合には最新の時刻ファイルの取得を行います
let callEvery3m = function(){
every3m();
};
setInterval( callEvery3m, 181 * 1000);
function every3m(){
if( document.getElementById("autoUpdate").checked){
getLatestTime();
}
};
表示ページ全体のソースコード
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>📢
雷観測</title>
<style>
*{ font-family:sans-serif;}
body{ font-size:16px; width:1024px; max-width:calc(100% - 80px); margin:auto;}
input[type=number]{ width:3rem;}
table, tr, td, th{ border-collapse:collapse; text-align:center; border-style:solid; border-width:1px 0; border-color:#d8d8db;}
th,td{ padding:2px 8px;}
th{ background-color:#f1f1f4;}
</style>
</head>
<body>
<div id="menu">
<button id="get">取得</button>
<input type="datetime-local" id="targetTime">
<select id="office"></select>
<select id="class20"></select>
<label><input type="checkbox" id="autoUpdate" checked>自動更新</label>
<label><input type="checkbox" id="sound">音声報知</label>
<label><input type="number" id="thresholdDistance" value="50">km以内を報知</label>
</div>
<div id="out"></div>
<script>
"use strict";
let globals = {};
getGlobals();
// 自治体の位置情報を取得
function getGlobals(){
let datas = {};
let promises = [];
let urls = {
"areas":"https://www.jma.go.jp/bosai/common/const/area.json",
"areaCenters":"https://www.jma.go.jp/bosai/common/const/xy.json",
"class20Limits":"https://www.jma.go.jp/bosai/common/const/class20relm.json"
};
for( let elemName in urls){
promises.push(
new Promise((resolve, reject) => {
fetch(urls[elemName])
.then((response) => response.json())
.then((response) => {
globals[elemName] = response;
resolve(elemName);
});
})
);
}
Promise.all(promises).then((values) => {
makeOfficeSelect();
getLatestTime();
});
}
// 都道府県の選択肢を作成
function makeOfficeSelect(){
let officeSelect = "";
const officeCodes = Object.keys(globals['areas']['offices']).sort((a,b)=>{ return a-b;});
for( let officeCode of officeCodes){
const officeName = globals['areas']['offices'][officeCode]['name'];
officeSelect += "<option value='" + officeCode + "'>" + officeName + "</option>";
}
document.getElementById("office").innerHTML = officeSelect;
makeClass20Select();
}
// 都道府県の変更時は市町村等の選択肢を再作成
document.getElementById("office").addEventListener("change", function(e){
makeClass20Select();
})
// 市町村等の選択肢を作成
function makeClass20Select(){
let class20Select = "";
const officeCode = document.getElementById("office").value;
for( let class10Code of globals['areas']['offices'][officeCode]['children']){
for( let class15Code of globals['areas']['class10s'][class10Code]['children']){
for( let class20Code of globals['areas']['class15s'][class15Code]['children']){
const class20Name = globals['areas']['class20s'][class20Code]['name'];
class20Select += "<option value='" + class20Code + "'>" + class20Name + "</option>";
}
}
}
document.getElementById("class20").innerHTML = class20Select;
}
// 「自動更新」にチェックが入っている場合、3分ごとに自動更新
let callEvery3m = function(){
every3m();
};
setInterval( callEvery3m, 181 * 1000);
function every3m(){
if( document.getElementById("autoUpdate").checked){
getLatestTime();
}
};
// 最新のLIDEN観測時刻を取得
function getLatestTime(){
fetch("https://www.jma.go.jp/bosai/jmatile/data/nowc/targetTimes_N3.json")
.then((response) => response.json())
.then((targetTimes) => {
for( let targetTime of targetTimes){
if( targetTime['elements'].includes("liden")){
let validtimeUtc = new Date( targetTime['validtime'].substring(0,4), targetTime['validtime'].substring(4,6)-1, targetTime['validtime'].substring(6,8), targetTime['validtime'].substring(8,10), targetTime['validtime'].substring(10,12));
console.log( validtimeUtc);
let validtimeJst = new Date( validtimeUtc.getTime() + 9 * 60 * 60 * 1000);
document.getElementById("targetTime").value = dateFormat( validtimeJst, 'y-m-dTh:n');
get();
break;
}
}
});
}
document.getElementById("get").addEventListener("click", function(e){
get();
});
// 発雷データを取得
function get(){
let targetTime = new Date( document.getElementById("targetTime").value);
let targetTimeUtc = new Date( targetTime.getTime() - 9 * 60 * 60 * 1000);
fetch("https://www.jma.go.jp/bosai/jmatile/data/nowc/" + dateFormat(targetTimeUtc,'ymdhns') + "/none/" + dateFormat(targetTimeUtc,'ymdhns') + "/surf/liden/data.geojson?id=liden")
.then((response) => response.json())
.then((lidens) => {
analyse( lidens);
});
}
// 発雷位置に近い自治体と距離の情報を付加
function analyse( lidens){
// 発雷位置を自治体からの距離に変換
for( let feature of lidens['features']){
let lat = feature['geometry']['coordinates'][1], lon = feature['geometry']['coordinates'][0];
let nearClass20s = [];
// 発雷位置が各市町村の東西南北端の間にある場合に、近隣市町村の候補としてリストアップ
for( let class20Code in globals['class20Limits']){
let nn = globals['class20Limits'][class20Code]['ne'][0], ee = globals['class20Limits'][class20Code]['ne'][1];
let ss = globals['class20Limits'][class20Code]['sw'][0], ww = globals['class20Limits'][class20Code]['sw'][1];
if( ss<=lat && lat<=nn && ww<lon && lon<=ee){
nearClass20s.push( class20Code);
}
}
// 近隣市町村候補が1つ以上ある場合は近い順に並べ、最も近い市町村を表示
if( 0<nearClass20s.length){
nearClass20s.sort((a,b)=>{
let latA = globals['areaCenters']['class20s'][a][0], lonA = globals['areaCenters']['class20s'][a][1];
let distanceA = distance( lat, lon, latA, lonA);
let latB = globals['areaCenters']['class20s'][b][0], lonB = globals['areaCenters']['class20s'][b][1];
let distanceB = distance( lat, lon, latB, lonB);
return distanceA - distanceB;
})
let nearestClass20 = nearClass20s[0], nearestClass20Name = globals['areas']['class20s'][nearestClass20]['name'];
feature['properties']['position'] = nearestClass20Name + "付近";
// 近隣市町村候補がない場合には各都道府県の中心位置からの距離を調べ、最も近いものとそこからのおよその方角距離を表示
}else{
let offices = Object.keys( globals['areas']['offices']);
offices.sort((a,b)=>{
let latA = globals['areaCenters']['offices'][a][0], lonA = globals['areaCenters']['offices'][a][1];
let distanceA = distance( lat, lon, latA, lonA);
let latB = globals['areaCenters']['offices'][b][0], lonB = globals['areaCenters']['offices'][b][1];
let distanceB = distance( lat, lon, latB, lonB);
return distanceA - distanceB;
})
let nearestOffice = offices[0], officeName = globals['areas']['offices'][nearestOffice]['name'];
let latA = globals['areaCenters']['offices'][nearestOffice][0], lonA = globals['areaCenters']['offices'][nearestOffice][1];
let dist = distance( lat, lon, latA, lonA), dir = degToDir(angle( lat, lon, latA, lonA));
feature['properties']['position'] = officeName + "の" + dir + " 約" + Math.round(dist/1000) + "km";
}
// 選択市町村からの距離を計算
const selectedClass20 = document.getElementById("class20").value, selectedClass20Name = globals['areas']['class20s'][selectedClass20]['name'];
let selectedLat = globals['areaCenters']['class20s'][selectedClass20][0], selectedLon = globals['areaCenters']['class20s'][selectedClass20][1];
let dist = distance( lat, lon, selectedLat, selectedLon), dir = degToDir(angle( lat, lon, selectedLat, selectedLon));
feature['properties']['fromSelectedClass20'] = selectedClass20Name + "の" + dir + " 約" + Math.round(dist/1000) + "km";
// 距離が一定以下の場合はフラグを立てる
if( dist/1000 <= document.getElementById("thresholdDistance").value){
feature['properties']['isNear'] = true;
}
}
display( lidens);
}
// 発雷状況を表示
function display( lidens){
let out = "", existNear = false;
out += "<table>";
out += "<tr><th>緯度</th><th>経度</th><th>大まかな位置</th><th>観測時刻</th><th>種別</th><th>選択市町村からの距離</th></tr>";
for( let feature of lidens['features']){
out += "<tr>";
out += "<td>" + feature['geometry']['coordinates'][1] + "</td>";
out += "<td>" + feature['geometry']['coordinates'][0] + "</td>";
out += "<td>" + feature['properties']['position'] + "</td>";
out += "<td>" + feature['properties']['obstimeJST'] + "</td>";
let type = feature['properties']['type'];
if( type==0 || type==1){
type = "雲間";
}else if( type==4){
type = "対地";
}
out += "<td>" + type + "</td>";
out += "<td>";
// 近い位置での発雷は強調表示
if( feature['properties']['isNear']){
out += "<strong>";
existNear = true;
}
out += feature['properties']['fromSelectedClass20'];
if( feature['properties']['isNear']){
out += "</strong>";
}
out += "</td></tr>";
}
out += "</table>";
document.getElementById("out").innerHTML = out;
// 近い位置での発雷があった場合かつ音声報知にチェックが入っていれば音声を鳴らす
if( existNear && document.getElementById("sound").checked){
let warningSound = new Audio('./tornado.mp3');
warningSound.play();
}
}
// 緯度経度から距離を求める関数。返り値はメートル単位。https://zenn.dev/bellbind/scraps/bb2cfb915430f5 を参考に作成
function distance( lat1, lon1, lat2, lon2){
const rad = Math.PI / 180, r = 6371000;
const p1 = lat1 * rad, p2 = lat2 * rad, q1 = lon1 * rad, q2 = lon2 * rad;
return r * Math.acos(Math.cos(p1) * Math.cos(p2) * Math.cos(q1 - q2) + Math.sin(p1) * Math.sin(p2));
}
// 緯度経度から方位を求める関数。返り値は-180°~180°。lat1,lon1からlat2,lon2がどちらの方角にあるかを、北を0°、東を90°として示す。https://zenn.dev/bellbind/scraps/bb2cfb915430f5 を参考に作成
function angle( lat1, lon1, lat2, lon2){
const rad = Math.PI / 180, r = 6371000;
const p1 = lat1 * rad, p2 = lat2 * rad, q1 = lon1 * rad, q2 = lon2 * rad;
const dq = q2 - q1, cosP2 = Math.cos(p2);
const y = cosP2 * Math.sin(dq);
const z = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * cosP2 * Math.cos(dq);
return Math.atan2(y, z) * 180 / Math.PI;
}
// 方位(度)を8方位に変換する関数
function degToDir( deg){
const dirs = [[22.5, "東"], [67.5, "北東"], [112.5, "北"], [157.5, "北西"], [202.5, "西"], [247.5, "南西"], [292.5, "南"], [337.5, "南東"], [360.1, "東"]];
for( let dir of dirs){
if( (deg+360) % 360 < dir[0]){
return dir[1];
}
}
}
// Date形式のデータを文字列に変換
function dateFormat( d0, f='y-m-dTh:n:s'){ // for DateAPI
let d = new Date( d0.getTime());
const j=["日","月","火","水","木","金","土"];const e=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
f=f.replace(/y/g,("000"+d.getFullYear()).slice(-4)).replace(/Y/g,d.getFullYear()).replace(/m/g,("0"+(d.getMonth()+1)).slice(-2)).replace(/M/g,d.getMonth()+1).replace(/d/g,("0"+d.getDate()).slice(-2)).replace(/D/g,d.getDate());
f=f.replace(/w/g,j[d.getDay()]).replace(/W/g,e[d.getDay()]);
f=f.replace(/h/g,("0"+d.getHours()).slice(-2)).replace(/H/g,d.getHours()).replace(/n/g,("0"+d.getMinutes()).slice(-2)).replace(/N/g,d.getMinutes()).replace(/s/g,("0"+d.getSeconds()).slice(-2)).replace(/S/g,d.getSeconds());
return f;
}
</script>
</body>
</html>