はじめに
文末の参考文献群に触発されて、自分用に気象庁ナウキャストを使った天気時計を作ってみたくなりました。
できあがったのがこちらです。
ワーキングデモは画像クリックか https://frogcat.github.io/tenki-gl/ からどうぞ。
ソースコードは https://github.com/frogcat/tenki-gl においてあります。
この記事では、これを作る過程の諸々を記録します。
1.地図ライブラリの選定
気象庁ナウキャスト は Leaflet を使っていますね。
単にラスタ画像のレイヤリングして真上から見てみたいのであれば Leaflet で十分なんとかなります。
ただ、今回はテレビの天気予報みたいに遠近のあるビューを作ったり、いろいろな方角から天気図を見てみたい、と思ったので MapLibre を選択しました。図は東京目線で西側を見てみたものですが、任意の方角・チルトで表示できます。(PC の方は Ctrl キーを押しながらドラッグしてみましょう)
2.背景地図
最初は 地理院タイル:写真:世界衛星モザイク画像 を使っていたのですが、ちょっと致命的な問題がありました。背景地図に雨や雲が焼き込まれているのでナウキャストのレイヤーとぶつかってしまうのです。
そこで雲のないオープンな衛星画像タイルを探したところ、USGS (アメリカ地質調査所) の公開している The Natinal Map のベースマップのひとつとして USGS Imagery Only というものがあり、これを使うと映えそうです。
ライセンスについては USGS:What are the Terms of Use/Licensing for map services and data from The National Map? によると、フリーでありパブリックドメイン(ただしクレジットを出すことを期待する)というものなので、ベースマップとして使用することも問題なさそうです。
注) ライセンス/利用規約についてはこの記事の記述を鵜呑みにせず、原文を確認してください
3.都道府県ポリゴン
衛星画像の上に都道府県ポリゴン風のものをのせています。実際にはポリゴンではなくてポリラインの集積です。
データソースとしては 地理院地図VECTOR を使っているのですが、ちょっとした問題があります。
出典: https://maps.gsi.go.jp/help/pdf/vector/dataspec.pdf
都道府県境界はズームレベル8〜10 の「境界(boundary)」として提供されています。これは都道府県の間の境界線で、海岸線を含まないことに注意が必要です。
海岸線はズームレベル4〜7の「海岸線(coastline)」として提供されています。これを使うことで海と陸の境界を描画することができます。
ズームレベル 4〜7 では海岸線を描画するよりなさそうです。
ズームレベル 8〜10 ではオーバーズームした海岸線+境界を描画することによって、都道府県ポリゴン的なものを描画できそうに見えます。
これを実現するために、以下のようなスタイルを定義しています。
const style = {
"version": 8,
"glyphs": "https://maps.gsi.go.jp/xyz/noto-jp/{fontstack}/{range}.pbf",
"sources": {
// ズームレベル4〜7しかカバーしない地理院地図VECTOR ソース、海岸線のソースとして。
// これをソースとすることでズームレベル 8〜 において ズームレベル7 のデータをオーバーズーミングして使用できる
"gsi_experimental_bvmap4-7": {
"type": "vector",
"tiles": [
"https://cyberjapandata.gsi.go.jp/xyz/experimental_bvmap/{z}/{x}/{y}.pbf"
],
"minzoom": 4,
"maxzoom": 7,
"attribution": "<a href='https://github.com/gsi-cyberjapan/gsimaps-vector-experiment'>地理院地図Vector</a>"
},
// 通常の地理院地図VECTOR ソース、都道府県境界のソースとして。
"gsi_experimental_bvmap4-16": {
"type": "vector",
"tiles": [
"https://cyberjapandata.gsi.go.jp/xyz/experimental_bvmap/{z}/{x}/{y}.pbf"
],
"minzoom": 4,
"maxzoom": 16,
"attribution": "<a href='https://github.com/gsi-cyberjapan/gsimaps-vector-experiment'>地理院地図Vector</a>"
},
"ortho": {
"type": "raster",
"tiles": [
"https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/{z}/{y}/{x}?blankTile=false"
],
"tileSize": 256,
"minzoom": 2,
"maxzoom": 8,
"attribution": "<a href='https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer'>USGS Imagery Only</a>"
}
},
"layers": [...]
}
};
地理院地図VECTOR のデータはほかにも都道府県ラベルの表示や、ズームしていくと鉄道路線が見えたり市区町村境界が見えたり、といった部分で使っていますが、詳細はソースをどうぞ。
4. ナウキャストの表示と更新
ナウキャストのレイヤー自体は PNG 地図タイルなのであまりむずかしいことはありません。ただ、MapLibre は「データソースの URL を更新するAPI」 がないので、それをどう扱うかという問題があります。
いろいろとやってみたのですが、最終的にはこちらの GitHub Issues の手法を参考にしました。
https://github.com/mapbox/mapbox-gl-js/issues/2941
5. UI
タブレットや PC でフルスクリーン表示させたかったので、画面右上にフルスクリーンボタンを設置しています。
このあたりの記事を参考に。
https://maplibre.org/maplibre-gl-js-docs/example/fullscreen/
実際には以下のように一行加えるだけです。
map.addControl(new maplibregl.FullscreenControl());
画面左上の時計についてはマップコンテナとは無関係に html body に直接挿入してしまってもよいのですが、フルスクリーンモードにした時に時計が表示されなくなってしまうという問題があります。FullscreenControl はマップコンテナをフルスクリーン表示するものなので、時計もマップコンテナ配下に置かれるように、IControl を実装したカスタムコントロールとして作成しています。
なお時計の下の「○分前の天気」の実体は HTML Select Element で、クリックするとプルダウンで過去の時点に切り替えることもできます。
たとえばもどる・すすむボタンで連続的に時系列を変化させてアニメっぽく、というのも考えてはみたのですがソースデータの URL を変更する方式だとフェイドアウト〜タイルの取得&逐次表示、という手順で描画されるので、断続的な印象です。
そういう表現が必要な場合にはプリロード&透過度のコントロールのアプローチがよいかと思います。
(追記) 6.ひまわり赤外画像の表示
台風観察用にひまわりの赤外画像を乗せてみてみたくなりました。実際に作ったものがこちらです。
ワーキングデモは画像クリックか https://frogcat.github.io/tenki-gl/himawari/#4.98/31.82/138.39/-47.6/47 でどうぞ。
基本的には 時刻JSON の取得先の変更、ナウキャストの URL テンプレートをひまわり赤外画像の URL テンプレートに変更するくらいなのですが、ひとつだけ注意点があります。
ナウキャストは背景画像の上に透過PNGでできた降雨レイヤーを乗せることでビューを作っています。
他方でひまわりは非透過の JPEG レイヤーが画面のすべてを描画し、その上に透過の海岸線レイヤーを乗せることでビューを構成しています(下図)。
画像出典: https://www.jma.go.jp/bosai/map.html
単純に背景画像の上にひまわりの JPEG レイヤーを乗せてしまうと、背景画像は完全に隠れてしまいます。
JPEG レイヤーに raster-opacity を設定することで背景を透けさせる、というのはベタなやりかたですが、全体に画面が暗くなりがちで好みではありません。
また、 Leaflet のような HTML Image をベースにした描画であれば CSS mix-blend-mode をたとえば light
に設定することで、黒部分を透過にして背景画像と赤外画像を合成するような効果も期待できます。しかし、 WebGL 系ではこの手の CSS のトリックは使えません。
ここでは ServiceWorker を実装することで取得した JPEG を Canvas に描画、 ImageData を操作することでモノクロ画像を白色+透過度画像に変換して、PNG として返すことで求める挙動を実現しました。
ServiceWorker に関しては以下のページをベースに
https://qiita.com/frogcat/items/87817567a5317d1ef5ee
こんなかんじで記述しています。
const gsidem2mapbox = function(data) {
var length = data.length;
for (var i = 0; i < length; i += 4) {
data[i + 3] = data[i];
data[i + 0] = 0xff;
data[i + 1] = 0xff;
data[i + 2] = 0xff;
}
};
self.addEventListener('fetch', (event) => {
if (!event.request.url.startsWith("https://www.jma.go.jp/bosai/himawari/data/satimg/")) return;
if (!event.request.url.endsWith(".jpg")) return;
console.log(event.request.url);
event.respondWith(async function() {
try {
const res = await fetch(event.request.url);
console.log(res);
if (!res.ok) {
//console.info("not ok", res); return res;
} else {
//console.info("ok", res);
const imageBitmap = await self.createImageBitmap(await res.blob());
const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
const context = canvas.getContext("2d");
context.drawImage(imageBitmap, 0, 0);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
gsidem2mapbox(imageData.data);
context.putImageData(imageData, 0, 0);
return new Response(await canvas.convertToBlob(), {
type: "image/png"
});
}
} catch (e) {
console.info("reject", e);
throw e;
};
}());
});
参考文献
https://qiita.com/e_toyoda/items/7a293313a725c4d306c0
https://qiita.com/Kanahiro/items/a1323fbdd550afbd257a