重ねるハザードマップの機能を地理院地図に追加してみる

## はじめに

地理院地図のユーザビリティで重ねるハザードマップの機能を利用したくなりました。

地理院地図は [GitHub からソースコード](https://github.com/gsi-cyberjapan/gsimaps/)が提供されており、また、重ねるハザードマップの情報も一部が[オープンデータ](https://disaportal.gsi.go.jp/hazardmapportal/hazardmap/copyright/opendata.html)として提供されています。第三者が自分好みに地理院地図や重ねるハザードマップのようなサイトを作るための環境が整えられていますので、これらを利用して、重ねるハザードマップのような機能を持ったオリジナル地理院地図を作ってみたいと思います。

作ったサイトはこちら。

https://mghs15.github.io/gsimaps-with-disaportal/

![イメージ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/06882499-98a5-a35f-9df3-b8e7731a5eb5.png)


:::note
ポイント
* 重ねるハザードマップで見れる災害リスク情報を追加
* 災害リスク情報を表示した情報で画面をクリックすると、周囲の当該災害リスクを表示可能
* 「ツール」→「リスク検索」モードで、クリック地点周辺のリスク情報をまとめて表示可能
:::

:::note warn
実験的な実装です。表示される情報は誤った処理をしている可能性が否定できません。**実際に災害リスクを確認される際は、自治体のハザードマップ等をご確認**ください。
:::


## 移植する機能

重ねるハザードマップから移植したい機能を抽出してみます。地理院地図と異なる機能はいくつかありますが、以下の３つの機能が重要と考えています。

1. **災害リスク情報**：地理院地図には掲載されていない情報があります
2. **ポップアップ機能**：地図上をクリックして、表示されている災害リスク情報の内容を表示できます。内部的には、タイルの色を読み取って、対応する災害リスク情報を表示しています
3. **リスク検索機能**：クリック地点の災害リスク情報をまとめて検索できます

移植にあたっての技術的な方針です。

災害リスク情報はライセンス上、全て掲載するわけには行きませんので、[オープンデータ](https://disaportal.gsi.go.jp/hazardmapportal/hazardmap/copyright/opendata.html)となっているものを追加します。

ポップアップ機能については、以前作成した[ハザードマップポータルサイトの災害リスクを矩形で読み込む実験](https://github.com/mghs15/disaster-risk-within-rectangle)を流用して、**１ピクセル単位のピンポイントではなく、周囲のピクセルも読み込む**ことにします。特に、「家屋倒壊等氾濫想定区域（氾濫流）」は、模様になっており、１ピクセル単位だと災害リスクを正しく読み込めませんが、周囲のタイルも読み込むことで、少なくともそのエリアのいずれかは区域内であることが分かります。

リスク検索機能は、ポップアップ機能の拡張ですが、今回追加する災害リスク情報（洪水、内水氾濫、高潮、津波、土砂災害）のタイルを全て確認し、ヒットしたものをポップアップとして表示します。**ヒットしたレイヤは、ユーザーが能動的に表示レイヤへ追加**できるようにしましょう。

なお、本家重ねるハザードマップには、住所検索時・現在地検索時に、リスクを列挙して表示する機能がありますが、これは、単に住所検索や現在地検索したい場合もあるため、今回の実装は見送りました。

:::note
ここでは、重ねるハザードマップの行動指南的なものではなく、選択をゆだねる地理院地図的なユーザビリティを重視しています。
:::

## 地理院地図のソースコードの拡張

機能移植に当たっては、地理院地図のソースコードは極力手を入れずに外部ファイルのみで実装したいところです。

最終的に、以下のような実装となりましたので紹介します。

:::note warn
なお、今回の移植に当たって、重ねるハザードマップのコードは参考にしましたが、追加機能部分のソースはほとんどフルスクラッチ（ChatGPT の支援あり）で作成しています。そのため、**重ねるハザードマップと全く同一の処理ではない**のでご注意ください。
:::

### レイヤの追加

レイヤの追加は、[レイヤ定義規約](https://github.com/gsi-cyberjapan/layers-dot-txt-spec)ファイルを追加すれば事足りるでしょう。

### ポップアップ機能

ポップアップは、地図のクリック時のイベントを追加すれば良さそうです。地理院地図は Leaflet ベースですので、Leaflet の仕様に従いながら追加実装をしていきます。 

地理院地図における Leaflet の Map オブジェクトは `GSI.GLOBALS.gsimaps._mainMap._map` のようですので、これに `on()` で click イベントを追加します。

```javascript:地図のクリック時イベントの追加
const gsimaps = GSI.GLOBALS.gsimaps;
const map = gsimaps._mainMap._map;
map.on('click', (e) => {
  // クリック時の処理 
});
```

ただし、Map オブジェクトが準備された後に処理の追加を行う必要があり、元のソースコードを修正する必要があります。ご丁寧に、`js/gsimaps.js` には、記載すべき場所にコメントが記されていたので、そこへ新たな処理を追加すれば OK でした。

![gsimapsの拡張.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/6d53f00a-642f-6b0f-4e11-9d0dd3c98453.png)

なお、作図や断面図作成の際にポップアップが表示されると邪魔なので、以下のように、作図や断面図作成でないか判定する処理を入れる必要があります。


```javascript:作図や断面図作成の際にポップアップを表示しない
// 作図中は反応させない
if(gsimaps._sakuzuDialog && gsimaps._sakuzuDialog.getVisible()) {
  return;
}
// 断面図作成中は反応させない
if(gsimaps._crossSectionViewDialog && gsimaps._crossSectionViewDialog.getVisible()) {
  return;
}
// 作図ダイアログ最小化中は反応させない
if(gsimaps._sakuzuDialog && gsimaps._sakuzuDialog.isMinimized()) {
  return;
}
// 断面図ダイアログ最小化中は反応させない
if(gsimaps._crossSectionViewDialog && gsimaps._crossSectionViewDialog.isMinimized()) {
  return;
}
```

ポップアップの処理自体は、[ハザードマップポータルサイトの災害リスクを矩形で読み込む実験](https://github.com/mghs15/disaster-risk-within-rectangle)を流用し、新たに作成した `js/disaportal_popup.js` へ機能をまとめています。

また、余談ですが、最初は色を災害リスクに変換するテーブルを１つにしていたのですが、土砂災害と浸水継続時間、氾濫流と河岸浸食が同じ色だったので、泣く泣くレイヤ ID 毎に分離しました。

### ツール設定とダイアログの追加

今回、地図をそのままクリックする通常モードの他、クリック地点周囲の災害リスク情報をまとめて検索するリスク検索モードの２種類を実装しなければなりません。

リスク検索モードについては、地理院地図の「ツール」群に追加することにしました。「リスク検索中であること」のみを示すダイアログを表示させ、その間は、地図をクリックした際はリスク検索結果をポップアップ表示するような挙動とします。

ツールの追加については、`js/pc.js` 及び `js/mobile.js` へ設定を追加すると、ツール一覧にボタンが表示されるようになります。このボタンの処理については、`js/gsimaps.js` に switch/case で条件分岐が記載されているため、ここに処理を書き込む必要が出てきます。

```javascript:ツールの追加による条件分岐（js/gsimaps.js）
// 拡張
case 'riskmatomete':
  // リスク検索　ダイアログ
  if (!this._riskmatometeDialog)
    this._riskmatometeDialog = new GSI.RiskMatometeDialog(
      dialogManager,
      map,
      {
        width: 350, left: windowSize.w - 370, top: 45,
        effect: CONFIG.EFFECTS.DIALOG,
        resizable: (GSI.Utils.Browser.isSmartMobile ? false : "all")
      }
    );
  this._riskmatometeDialog.show();

  break;
```

追加で呼び出すダイアログ（上記コードの `GSI.RiskMatometeDialog`）は、`GSI.Dialog` Class を拡張することで実装できます。この定義自体は、外部ファイルで設定できましたので、`js/gsimaps.js` を汚す心配はありません。

なお、ツール群については、gsimaps オブジェクトから直接操作も可能なようです。

```javascript:ツールダイアログを直接開く
const gsimaps = GSI.GLOBALS.gsimaps;
gsimaps._onMenuItemClick({item:{id:'riskmatomete'}});
// ID 'riskmatomete' のツールが開く
```

### レイヤの操作

クリック時の通常ポップアップでは、どのレイヤが最上位に表示されているか把握する必要があります。また、リスク検索では、レイヤに表示されていないレイヤの情報を表示されるため、そのレイヤを新たに追加したくなります。

このように、レイヤの表示状態を把握したり、レイヤの操作を行う必要がありますが、`js/gsimaps.js` へ手を入れずに直接操作する方法を見つけることができませんでした。

そこで、トリッキーですが、URL のハッシュパラメータを操作することで、表示状態を操作することとしました。地理院地図の URL は、ハッシュで地図の状態（選択されているレイヤやその表示・非表示等）を記録・再現する機能があります。以下は、地理院地図のハッシュパラメータの例です。

```text:地理院地図 URL のハッシュ部分の例
#15/33.555969/133.550341/
 &base=std
 &ls=std%7C01_flood_l2_shinsuishin_data%7C04_tsunami_newlegend_data
 &blend=10
 &disp=101
 &vs=c1g1j0h0k0l0u0t0z0r0s0m0f1&d=m
```

ここで、レイヤ操作に重要なのは、`ls=` 部分、`disp=` 部分及び `blend=` 部分です。レイヤ ID は、下から順に `ls=` 部分に `%7C` で区切られて格納されており、それに対応する順番で、表示(1)と非表示(0)の設定が `disp=` 部分に格納されています。`blend=` 部分は乗算処理の設定で、一番下のレイヤを除いて設定されるようで、パラメータ数がレイヤ数に比べて１小さいです。

上の例ですと以下のようにレイヤが重なっています。

|レイヤ順|`ls`|`disp`|`blend`|
|:-:|:-:|:-:|:-:|
|一番上|04_tsunami_newlegend_data|1（表示）|0（乗算なし）|
||01_flood_l2_shinsuishin_data|0（非表示）|1（乗算あり）|
|一番下|std|1（表示）|（設定されない）|


こちらの URL ハッシュからレイヤ情報を取得したり、ハッシュを変更して作った新しい URL へ遷移させることにより、レイヤの表示状態を取得・操作することができます。なお、URL と地図の状態との連動は時々遅延が生じるっぽいのでご注意ください。

```javascript:ハッシュからレイヤの状態取得と操作の例
const _tmpUrl = new URL(window.location.href);
const _tmpHash = _tmpUrl.hash;

// ハッシュの変更
const _newHash = _tmpHash.replace( ... )

// URL の再生成と設定
const _newUrl = _tmpUrl.origin + _tmpUrl.pathname + _newHash;
window.location.replace(_newUrl);
```

### 住所検索との連動

重ねるハザードマップでは、住所検索をすると、リスクをまとめて表示してくれます。今回の実験でも同じようなことができないか試してみました。

結果としては、検索結果をクリックした際に、地図をその検索結果の場所させる機能（`GSI.SearchResultDialog.showResult()` および `GSI.SearchResultDialog.onResultClick()`）がありますが、ここを拡張することでリスク検索を同時に表示させることができます。

```Javascript:検索結果の地点へ移動とともにリスク検索結果を表示（js/gsimaps.js）
  showResult: function () {
    //（省略）
    var results = [this.addressResult, this.chimeiResult];
    //（省略）
    // 拡張
    if(DISAPORTAL.GLOBAL.isRiskMatometeMode){
      console.log(results);
      const resultItem = results[0][0] || results[1][0];
      if(!resultItem) return;
      this.onResultClick(resultItem);
    }
    
  },
  onResultClick: function (resultItem) {
      //（省略）
      // 地図を検索結果に合わせて移動
      this.map.setView([latitude, longitude], zoom);
      
      // 拡張
      if(DISAPORTAL.GLOBAL.isRiskMatometeMode){
        console.log("住所検索結果の選択を gsimaps の 拡張部分から呼び出し")
        console.log(resultItem)
        DISAPORTAL.Utils.getRisk({
          latlng: { lat: latitude, lng: longitude }
        }, resultItem.properties.title);
      }
      //（省略）
  },
```

なお、検索結果の一番最初のものについては、最初からリスク検索ポップアップを出すこととしました（すこし、行動指南的かもしれませんが……）。ついでに、クエリパラメータで `q=検索文字列` と設定することで、地図表示時に住所検索を一緒にするようにしてみました。

例：https://mghs15.github.io/gsimaps-with-disaportal/?q=徳島

:::note warn
正直、「関東平野」や「香川県」といった広域のものについては、リスク検索ポップアップにそぐわないと思うので、ある程度フィルタをする必要があるかもしれません。
:::

技術的には、クエリパラメータ受け取った検索文字列を、検索窓（`#query`）に入力のうえ、submit イベントを起こして住所検索を実行させます。

```Javascript:住所検索を javaScript から操作
  // 住所関連時のイベント
  if(queryParams.has("q")){
    const q = document.getElementById("query");
    q.value = queryParams.get("q"); // 検索窓に検索文字列を入力
    
    const form = document.getElementById("search_f");
    const evSubmit = new Event('submit');
    form.dispatchEvent(evSubmit); // submit イベントを実行させる
    
  }
```

なお、リスク検索モードとするために、住所検索に先立ち、リスク検索用ダイアログを表示させています（ダイアログを表示させれば、リスク検索モードになる設定となっています）。こちらは素直に、gsimaps オブジェクトから操作ができました。

```Javascript:ダイアログの表示操作
const gsimaps = GSI.GLOBALS.gsimaps;
gsimaps._onMenuItemClick({item:{id:'riskmatomete'}});
// この後に住所検索操作関係の記述
```

:::note warn
当初は、トリッキーではありますが、検索結果欄（`.searchresultdialog_ul`）の変更を監視し、変更即ち住所検索結果の更新があれば、一番最初の検索結果に対して click イベントを起こしていました。一方、予期しない挙動も多く、結局 gsimaps.js の中に記述することとしました。
:::
```Javascript:（不採用）検索結果欄の変更監視
    const targetNode = document.querySelector(".searchresultdialog_ul");
    const config = { attributes: true, childList: true, subtree: true };
    const callback = (mutationList, observer) => {
      console.log("住所検索結果リストの変更を検知");
      const aqs = document.querySelectorAll(".searchresultdialog_ul li a");
      const evClick = new Event('click');
      aqs[0].dispatchEvent(evClick); // click イベントを実行させる
      // リスク検索の処理自体は、gsimaps.js 内に直接追加
    };
    const observer = new MutationObserver(callback);
    observer.observe(targetNode, config);
```

### フッターへ情報の追加

技術調査の観点から、フッターへ情報を追加する方法も検討してみました。



フッターは、`GSI.Footer` Class で管理されており、インスタンスは `GSI.GLOBALS.gsimaps._mainMap._footer` となります。今回は、インスタンス化が完了後に情報を追記するための拡張を施してみます。

たとえば、地図の中心部分の災害リスク情報を取得しようとすると、その表示のための DOM と地図移動時の処理は以下のように設定できます。

```javascript:フッターの設定
  const footer = GSI.GLOBALS.gsimaps._mainMap._footer;
  footer._createDisaportalContainer = function (parentContainer) {
    const strNoData = "";
    var container = $("<div>").addClass("item-frame");
    var disaportalLabel = $("<span>").addClass("heading").html("表示中の災害リスク: ");
    this._disaportalView = $("<span>").addClass("disaportalinfo").html(strNoData);

    container.append(disaportalLabel).append(this._disaportalView);
    
    parentContainer.prepend(container);
    return container;
  }
  footer._disaportalContainer = footer._createDisaportalContainer(footer._container);
  
  // 移動時イベント設定
  const map = GSI.GLOBALS.gsimaps._mainMap._map;
  map.on('moveend', (e) => {
    const c = { latlng: map.getCenter() }; // 中心位置の取得
    //（省略）
    footer._disaportalView.html(riskInfoStr); // フッターへ設定(jQuery による)
  });
```

一う、地理院地図のフッターは、非表示、1段階表示、2段階表示と変更できますが、この時の表示・非表示対象の DOM に追加してあげる必要があります。これは `gsimaps.js` へ直接書き込んであげれば済む話ではありますが、単純に DOM を表示・非表示するだけなので、元の関数をオーバライドする形で実装してみました。

```javascript:フッターのボタン操作時の処理をオーバーライドで拡張
  // 1段階表示
  const originalStartMiniMode = footer._startMiniMode;
  footer._startMiniMode = function (b) {
    footer._disaportalContainer.hide();
    return originalStartMiniMode.apply(this, [b]);
  }
  // 2段階表示
  const originalStartLargeMode = footer._startLargeMode;
  footer._startLargeMode = function (b) {
    footer._disaportalContainer.show();
    return originalStartLargeMode.apply(this, [b]);
  }
```

:::note warn
なお、フッターに災害リスク情報を実装すると、フッターの情報量がかなり多くなり、地図を覆いつくすことになります。地図と連動しながら、地図の中心位置の情報を見れるのがフッターの良い点と考えられますが、**フッターが肥大化しすぎると肝心の地図が見れなくなります**。
そもそも、今回の検証サイトでは、地図をクリックすることで災害リスク情報を表示する機能があるため、わざわざ地図の中心の位置の情報を表示する必要性も小さいと考えられます。クリック地点と地図中心部の違いで誤解を招く可能性もあります。
今回は技術的な興味から実装してみましたが、**機能面を考えるとフッターへの情報追加は見送るべき**かと思っています。
::: 

（肥大化したフッターの例）
![肥大化するフッター.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/04654545-abb9-4c43-a7f8-aad5e0660847.png)


### 公式サイトと間違わないように

国土地理院のサイトではない旨のアラートを付けたほか、`css/gsimaps.css` を修正して、サイトのテーマカラーを変更しています。

## レポジトリ

レポジトリはこちら。

https://github.com/mghs15/gsimaps-with-disaportal

## 感想

オープンデータ・オープンソースの恩恵を受け、自分好みのサイトを作成することができました。

地理院地図のソースコードは公開されているものの、中身の情報はあまり存在しないため、身構えていましたが、いざ手をつけてみると意外とソースコードを汚さずに拡張が効くことが分かりました。一方、頑張って外部ファイルで操作するよりも、直接中身をいじってしまった方が楽な場合も多く、保守運用の観点で判断が難しいです。

まだ課題はあるので、色々と勉強してみたいです