[Astro] #172 Elevation Profile — 地図に線を引いて、標高の断面図を描く

[Astro] #172 Elevation Profile — 地図に線を引いて、標高の断面図を描く

はじめに

前回は、DOT を描くツールでした。

今回は、「未開拓アイデア」の 3 つ目、断面図です。地図の上に線を引くと、その線に沿った標高の変化を、グラフで見せます。

標高の読み取りは、以前作った Terrain Explorer の仕組みを流用しました。

今回の要点は、次の 5 つです。

  1. 標高は、国土地理院の標高タイル(ズーム 14、約 10 m メッシュ)を、ブラウザが直接読む
  2. 線を一定の間隔で刻んで、双線形補間で標高を読む
  3. 海などの「データなし」は、描画では 0 m、統計からは除く
  4. 断面図は、画面と PNG 書き出しで、同じ描画関数を使う
  5. 地図と断面図が、ホバーで連動する

完成画面

富士山の周りに線を引いたところ

地図に折れ線を引いて、下に断面図が出た Elevation Profile

Pointを複数作成

地図に折れ線を引いて、下に断面図が出た Elevation Profile

左が場所と点のリスト、中央が地図と断面図、右が要約と書き出しです。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 です。