[Astro] #172 Elevation Profile — 地図に線を引いて、標高の断面図を描く
はじめに
前回は、DOT を描くツールでした。
[Astro] #171 DOT Graph Visualizer — Graphviz WASM で DOT を描き、つながりを光らせる
DOT を図にするツールを作りました。
lain-lab.com今回は、「未開拓アイデア」の 3 つ目、断面図です。地図の上に線を引くと、その線に沿った標高の変化を、グラフで見せます。
標高の読み取りは、以前作った Terrain Explorer の仕組みを流用しました。
[Astro] #164 Terrain Explorer — 地理院タイルの地形を立体にして、VRで歩くツールを作成 // PROTOCOL.LAIN
Astro のページ 1 枚と three.js(約 1,300 行)で作った地形ビューアの実装記録。標高タイル(PNG の色 = 標高)の読み方、Web メルカトルの座標、地図の自作、標高の格子から面を作る方法、足元だけ細かい地面を読み足すチャンクの仕組み、遠景との継ぎ目の処理、WebXR での模型と実寸の切り替え、偽のタイルサーバーを使った試験、ハマりどころまで。
lain-lab.com今回の要点は、次の 5 つです。
- 標高は、国土地理院の標高タイル(ズーム 14、約 10 m メッシュ)を、ブラウザが直接読む
- 線を一定の間隔で刻んで、双線形補間で標高を読む
- 海などの「データなし」は、描画では 0 m、統計からは除く
- 断面図は、画面と PNG 書き出しで、同じ描画関数を使う
- 地図と断面図が、ホバーで連動する
完成画面
富士山の周りに線を引いたところ
Pointを複数作成
左が場所と点のリスト、中央が地図と断面図、右が要約と書き出しです。6 点の折れ線で、58.3 km、5837 点でした。
ヘルプのダイアログ
動画
標高タイルの読み取りは、流用した
標高タイルは、ズーム 14 の PNG です。1 画素が、R、G、B の 3 バイトで、1 つの数値を表します。
- x = R × 65536 + G × 256 + B
- x が 2 の 23 乗より小さいとき:標高 = x × 0.01 m
- x がちょうど 2 の 23 乗:データなし(海など)
- x が 2 の 23 乗より大きいとき:標高 = (x − 2 の 24 乗) × 0.01 m(負の値)
画像を、ブラウザが勝手に色調整すると、数値が狂います。そのため、createImageBitmap に、colorSpaceConversion: 'none' と premultiplyAlpha: 'none' を渡しています。ここは、Terrain Explorer と同じです。
違うのは、使い方です。Terrain Explorer は、範囲の全体を貼り合わせて、立体にしました。今回は、線の上の点だけが要るので、タイルを 1 枚ずつ読んで、256 × 256 の数値の並びにして、覚えておきます。覚えるのは、最大 600 枚です。同時に取りに行くのは、6 枚までにしています(相手は公共のサーバーです)。
線を刻んで、標高を読む
点の列を、一定の間隔(既定は自動、10 m 前後)で刻みます。
- 刻むのは、メルカトルの座標の上です。地図で見える直線に沿います
- 距離は、刻んだ点どうしの、大円距離(haversine)を足していきます
for (let k = 0; k < n; k++) {
const t = k / n;
const q = pxToLonLat(px[s].x + (px[s + 1].x - px[s].x) * t,
px[s].y + (px[s + 1].y - px[s].y) * t, 20);
push(q.lon, q.lat);
}
刻んだ点の標高は、まわりの 4 画素から、双線形補間で求めます。画素の中心を、格子の点として扱います。4 つのうち、データなしの画素があるときは、それを除いて、重みを取り直します。海岸で、いきなり 0 m に引っ張られないようにするためです。
bilinear(getv, fx, fy) {
const x0 = Math.floor(fx - 0.5), y0 = Math.floor(fy - 0.5);
const u = fx - 0.5 - x0, v = fy - 0.5 - y0;
let sum = 0, w = 0;
const add = (ix, iy, wt) => {
if (wt <= 0) return;
const h = getv(ix, iy);
if (!Number.isNaN(h)) { sum += h * wt; w += wt; }
};
add(x0, y0, (1 - u) * (1 - v)); add(x0 + 1, y0, u * (1 - v));
add(x0, y0 + 1, (1 - u) * v); add(x0 + 1, y0 + 1, u * v);
return w > 0 ? sum / w : NaN;
}
読む点が多いので、次の制限を付けました。
- 刻む間隔は、「選んだ間隔」と「全長を 6000 で割った値」と「1 m」の、いちばん大きいもの。これで、点が 6000 を超えません
- 必要なタイルが 500 枚を超える線は、断ります
- 線を引き直したときは、世代番号を付けて、古い計算の結果を捨てます
データなしの扱いで、数値が壊れた
海は、標高タイルが「データなし」か、タイルが無い(404)かのどちらかです。
描画では、データなしを 0 m として、青い帯にしました。ここまでは、よかったのですが、統計にも 0 m として入れたところ、海岸の境目で、段差ができました。モックのタイルで、海をまたぐ線を試したときに、最大勾配が、400 % や 900 % になりました。
そこで、統計の計算では、データなしを含むところを、数えないことにしました。
- 増減量:前後どちらかが、データなしの区間は、足さない
- 最大勾配:約 50 m の区間に、データなしが 1 つでも入っていたら、その区間は、見ない
- 平均勾配:始点か終点が、データなしのときは、0 とする
最大勾配は、約 50 m の区間の、標高差を距離で割った値を、1 点ずつずらして、いちばん急なところを探しています。10 m メッシュなので、崖の実際の傾斜より、緩く出ます。
断面図を描く
断面図は、canvas に直接描きます。描画の関数は、1 つで、画面と PNG の書き出しで、共用しています。違うのは、配色と大きさだけです。画面は暗い配色、書き出しは、白い背景で、1600 × 640 です。
- 目盛りは、1、2、5 の刻みで決めます
- データなしの帯は、青で塗ります
- 縦の倍率:auto は枠いっぱい、×1 は縦横を同じ縮尺にします。×1 のとき、枠に収まらないなら、枠に合わせます
- ホバーすると、距離、標高、前後 25 m の勾配が出ます
×1 にすると、富士山でも、ほとんど平らに見えます。縦の倍率は、画面の右上に、vertical ×4.5 のように出して、見る人が、そのことを忘れないようにしています。
地図の操作
地図は、地理院タイルを canvas に並べるだけの、小さな自作です。
- クリック:点を足す
- 点のドラッグ:点を動かす
- 右クリック:点を消す
- 空いているところのドラッグ:地図を動かす
- ホイール:カーソルの位置を中心に、拡大縮小(ズーム 4 から 17)
- Ctrl+Z:点を 1 つ戻す
点は、URL のハッシュに入ります(#p=経度,緯度;経度,緯度)。リンクを開くと、同じ線が戻ります。背景は、地図、淡色、航空写真、色別標高図から選べます。
断面図の上にマウスを乗せると、地図に印が出ます。逆に、地図の線の上にマウスを乗せると、断面図に印が出ます。
テスト
実際の地理院のサーバーには、私の作業環境から、届きません。そこで、モックのタイルサーバーを、Python(PIL と numpy)で作りました。
- 標高:富士山の位置に、高さ 3776 m の円錐の山
- データなし:経度 138.55 度より西
- 負の値:一部に、−250 m の窪み
- 背景:写真、地図などは、無地のタイルに、タイルの番号を書いたもの
これを、Playwright で動かす Chromium から読ませました。確かめたのは、次のことです。
- プリセットの選択、点の追加、ドラッグ、右クリックでの削除
- 断面図のホバーと、地図の印の連動
- 縦の倍率の切り替え
- CSV、GeoJSON、PNG の書き出し
- URL のハッシュからの復元
- データなしの帯の表示、JS のエラーが無いこと
DOM が要らない部分(座標の変換、距離、補間、統計、ハッシュ、書き出しの文字列)は、Node で確かめました。
そのあと、サイトに入れて、本物のタイルで動くことを、確認できました。住所検索も、問題なく動きました。
ハマりどころ
1. 構文エラー
最初に書いたコードに、else の無い三項演算子が 1 つあって、構文エラーでした。Node で、スクリプトを取り出して --check すれば、すぐ見つかります。ブラウザで開く前に、まず、構文だけ確認するようにしています。
2. 海との境目の統計
上に書いたとおりです。見た目は、それらしい断面図でも、数字が、おかしくなることがあります。描画と、統計は、別に確認します。
3. ×1 の縦軸
×1 のとき、縦軸が、必要以上に高くなる(1500 m から 3776 m の山で、縦軸が 8500 m まで伸びる)ことがありました。縦横を同じ縮尺にして、枠に収めると、そうなります。仕様として、そのままにしています。
未確認のこと・未対応のこと
- 100 km を超えるような長い線の、速さ:測っていません。タイルの数が増えるので、時間がかかります
- 最大勾配の値:10 m メッシュで、約 50 m の区間の平均です。富士山の火口の近くで、100 % を超える値が出ましたが、本当にそうなのか、確かめていません
- プリセットの座標:おおよその値です
- スマホなどのタッチ操作:確かめていません
- 5 m メッシュ(ズーム 15):使っていません
- 読み込みは、メインスレッドで行います。Web Worker には、移していません
- 複数の線の重ね描き、GPX の読み込み:ありません
おわりに
Terrain Explorer で作った、標高タイルの読み取りが、そのまま使えたので、標高を出すところは、速く終わりました。時間がかかったのは、地図の操作と、統計の細かいところでした。
3 つ並べた「未開拓アイデア」の最後を、これで作り終えました。
実装メモは、docs/elevation-profile.md に、残してあります。次にやるなら、5 m メッシュと、Web Worker です。