ZENRIN Maps APIで小学校区、中学校区を表示してみた

# はじめに
ZENRIN Maps APIを使用して 小学校区/中学校区 を地図に表示してみました
APIから取得したバイナリデータを地図に重ねて表示する部分に工夫が必要だったので残しておきます

# 準備
ZENRIN Maps APIで地図を表示するためには APIキー が必要です
登録して取得しておきましょう

[無料トライアルページ](https://www.zenrin-datacom.net/solution/zenrin-maps-api/trial?fm_cp=6757baf3203e3a00bb118509&fm_mu=67639e6c0dbf8e060dd62db3&utm_campaign=6757baf3203e3a00bb118509&utm_medium=else&utm_source=Qiita39)
2ヶ月間無料で利用できます

# 完成イメージ
## 小学校区の表示
![48f2ae66bbe03ca64cfd-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4001795/8ac4ea57-9414-4bdf-a558-584df6761732.png)

## 中学校区の表示
![48f2ae66bbe03ca64cfd-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4001795/2f909be1-5d76-4097-8546-05d51a4b203f.png)

左下のトグルスイッチで 小学校区/中学校区 の表示を切り替えられます


# 使用したAPI
[データ重畳[小学校区]](https://developers.zmaps-api.com/v20/reference/webAPI/wms/eschool.html)
[データ重畳[中学校区]](https://developers.zmaps-api.com/v20/reference/webAPI/wms/jhschool.html)

このAPIから学校区の画像を取得して、
その画像を地図に重ねる方法をとりました

学校区に関する説明は[コチラ](https://developers.zmaps-api.com/v20/reference/appendix/23_school_area.html)

## APIから取得できる画像
![48f2ae66bbe03ca64cfd-3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4001795/bae6a774-a994-448f-8a6c-5cd81d8ad173.png)

指定する範囲(緯度経度＆サイズ)の学校区をあらわす透過画像を取得できます
学校区のポリゴンと学校の場所を示すアイコンが含まれます
これらは取得時のパラメータを指定することで、ポリゴンだけ/アイコンだけを指定することもできるようです


# コード全体
```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>学校区表示</title>

    <script src="https://test-js.zmaps-api.com/zma_loader.js?key=[APIキー]&auth=referer"></script>
    <script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>

  </head>
  <body>
    <div id="ZMap"></div>

    <div id="selectBox">
      <div class="toggle">
        <input class="toggle-button" type="checkbox" id="eschool" name="switch1">
        <label class="toggle-label" for="switch1">小学校区</label>
      </div>
      <div class="toggle">
        <input class="toggle-button" type="checkbox" id="jhschool" name="switch2">
        <label class="toggle-label" for="switch2">中学校区</label>
      </div>
    </div>

    <script src="js/app.js"></script>
    <link rel="stylesheet" href="css/style.css">
  </body>
</html>

```

```javascript:js/app.js
let map;
let layer = {'eschool': null, 'jhschool': null}; // 地図に重ねるウィジェットを保持する変数

ZMALoader.setOnLoad(function (mapOptions, error) {
  if (error) {
    console.error(error)
    return
  }

  const lat = 35.681406, lng = 139.767132;
  const mapElement = document.getElementById('ZMap');
  mapOptions.center = new ZDC.LatLng(lat, lng);

  mapOptions.zoom = 16;
  mapOptions.mouseWheelZoom = false;
  mapOptions.touchPinchZoom = false;

  mapOptions.zipsMapType = 'gZ6ujTVH';

  map = new ZDC.Map(
    mapElement,
    mapOptions,
    function() {
      map.addControl(new ZDC.ZoomButton('top-right'));
      map.addControl(new ZDC.Compass('top-right'));
      map.addControl(new ZDC.ScaleBar('bottom-left'));

      // 小学校区/中学校区 のトグルスイッチをONにしたときに学校区を地図上に表示
      $('.toggle-button').on("click", function() {
        toggleDisabled($(this).prop("checked")); // 表示処理中はスイッチを操作させないため
        drawLayer(this); // ONになったものを表示
      });

      // 地図スクロール操作後に再描画
      map.addEventListener('scrollend', redrawLayer);
      // 地図のズームが変わったら再描画 但しマウスホイールではなく右上に配置したZoomButton操作
      map.addEventListener('zoom_changed', redrawLayer)
    },
    function() {
    }
  );
})

// 学校区の取得と表示  
const setLayer = (target) => {

  if(layer[target]) map.removeWidget(layer[target]);

  size = map.getMapSize(); 
  params = {
    'VERSION': '1.3.0',
    'REQUEST': 'GetMap',
    'LAYERS': 'lp1,ls1',
    'CRS': 'EPSG:3857',
    'BBOX': getBBOX(), // 画面全体に表示
    'WIDTH': size.width,
    'HEIGHT': size.height,
    'FORMAT': 'image/png',
    'INFO_FORMAT': 'application/json'
  }

  xhr = new XMLHttpRequest();
  xhr.open('POST', `https://test-web.zmaps-api.com/map/wms/${target}`);
  xhr.setRequestHeader('x-api-key', '[APIキー]');
  xhr.setRequestHeader('Authorization', 'referer'); 
  xhr.setRequestHeader('Referer', `${location.protocol}//${location.host}`);
  xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
  xhr.responseType = 'blob';
  xhr.onload = function(e){
    if (this.status == 200) {
      var widget = new ZDC.UserWidget(
        map.getLatLngBounds().getNorthWest(),
        {
          htmlSource: `<img id="layer_${target}" src="">`,
          propagation: true
        }
      );
      map.addWidget(widget);
      layer[target] = widget;

      (document.getElementById(`layer_${target}`)).src = (window.URL || window.webkitURL).createObjectURL(this.response);

      toggleDisabled(false);
    };
  }
  xhr.send(new URLSearchParams(params));
}

const toggleDisabled = (flag) => {
  $.each( $('.toggle-button'), function(idx, target){
    $(target).prop('disabled', flag);
  });
}

const drawLayer = (target) => {
  if($(target).prop("checked")){
    setLayer( $(target).attr('id') );
  }else{
    map.removeWidget(layer[ $(target).attr('id') ]);
  }
}

const redrawLayer = () => {
  for (const target of $('.toggle-button')){
    if( $(target).prop("checked") ) drawLayer( target );
  }
}

// 画面表示領域の 左下,右上 の座標を取得する関数
const getBBOX = () => {
  bound = map.getLatLngBounds()
  p1 = bound.getSouthWest()
  p2 = bound.getNorthEast()

  p1 = map.latlngToWebMercator(p1)
  p2 = map.latlngToWebMercator(p2)
  return p1.concat(p2).join(',')
}
```

```css:css/style.css
body {margin: 0; padding: 0;}
#ZMap {position: absolute; top: 0; bottom: 0; width: 100%;}

#selectBox {
  position: absolute;
  left: 20px;
  bottom: 40px;
  border: 1px solid #3d3d3d;
  background-color: #fff;
  z-index: 100;
  padding: 10px 20px;
}

.toggle {
  display: flex;
  justify-content: center;
  align-items: center;
  margin: 10px 0;
}
.toggle-label {
  cursor: pointer;
  margin-left: 10px;
}
.toggle-button {
  position: relative;
  width: 60px;
  height: 30px;
  margin: 0;
  vertical-align: top;
  background: #ffffff;
  border: 1px solid #bbc1e1;
  border-radius: 30px;
  outline: none;
  cursor: pointer;
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  transition: all 0.3s cubic-bezier(0.2, 0.85, 0.32, 1.2);
}
.toggle-button::after {
  content: "";
  position: absolute;
  left: 3px;
  top: 1.5px;
  width: 25px;
  height: 25px;
  background-color: #95b95e;
  border-radius: 50%;
  transform: translateX(0);
  transition: all 0.3s cubic-bezier(0.2, 0.85, 0.32, 1.2);
}
.toggle-button:checked::after {
  transform: translateX(calc(100% + 3px));
  background-color: #fff;  
}
.toggle-button:checked {
  background-color: #95b95e;
}
.toggle-button:disabled {
  opacity: 0.6;
}
```

# 解説
## 画像取得と地図への表示
今回のメイン部分です
APIから画像を取得する処理はXMLHttpRequestで実装しました

※上記コードでは対象API名を関数に渡す引数(eschool/jhschool)で切り替えていますが、
解説では便宜的に"eschool"に固定しています

```javascript
xhr = new XMLHttpRequest();
xhr.open('POST', `https://test-web.zmaps-api.com/map/wms/eschool`);
// リクエストヘッダをセット
xhr.setRequestHeader('x-api-key', '[APIキー]');
xhr.setRequestHeader('Authorization', 'referer'); 
xhr.setRequestHeader('Referer', `${location.protocol}//${location.host}`); // 表示するURLを設定している
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.responseType = 'blob';
// レスポンスを受け取った後の処理
xhr.onload = function(e){
  if (this.status == 200) {
    // srcが空の画像レイヤをウィジェットで用意しておく
    var widget = new ZDC.UserWidget(
      map.getLatLngBounds().getNorthWest(),
      {
        htmlSource: `<img id="layer_eschool" src="">`, // 空の画像レイヤ
        propagation: true
      }
    );
    // 画像レイヤを地図に重ねる
    map.addWidget(widget);

    // 空の画像レイヤ部分にAPIから取得した画像データを埋め込む
    (document.getElementById(`layer_eschool`)).src = (window.URL || window.webkitURL).createObjectURL(this.response); // Blobデータを表示するURLを生成する
  };
}
// リクエスト
xhr.send(new URLSearchParams(params));
```

fetchで実装する場合はこんな感じ
```javascript
fetch("https://test-web.zmaps-api.com/map/wms/eschool", {
    method: "POST",
    headers: { // 認証関連
      'x-api-key': '[APIキー]',
      'Authorization': 'referer',
      'Referer': `${location.protocol}//${location.host}`,
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: new URLSearchParams(params) 
}).then(response => {
  return response.blob(); // Blob形式でデータ取得
}).then(response => {
  var widget = new ZDC.UserWidget(
    map.getLatLngBounds().getNorthWest(),
    {
      htmlSource: `<img id="layer_eschool" src="">`,
      propagation: true
    }
  );
  map.addWidget(widget);
  
  (document.getElementById(`layer`)).src = (window.URL || window.webkitURL).createObjectURL(response);
});
```

:::note info
APIの返却値はPNG形式のバイナリデータです
バイナリデータを受け取ってimgタグでその画像を表示しています
:::

## 表示領域が変わったときの処理
```javascript
// 地図スクロール操作後に再描画
map.addEventListener('scrollend', redrawLayer);
// 地図のズームが変わったら再描画 但しマウスホイールではなく右上に配置したZoomButton操作
map.addEventListener('zoom_changed', redrawLayer)
```

地図をスクロールで移動したときと拡大縮小したときに、
表示した学校区のエリアを更新して再描画するようにしています

地図操作をキャッチするイベントが用意されいるのでそれを利用します
[用意されているイベント](https://developers.zmaps-api.com/v20/reference/javascriptAPI/map.html#mapevents)



# まとめ
ZENRINMapsAPIで小学校区、中学校区を地図に表示してみました
流れは
1. APIから画像データ(バイナリ)を取得
2. UserWidgetで用意した空の画像レイヤを作成
3. Blob形式データを表示するurlを生成
4. 画像レイヤのsrcにurlをセットする

ポイントは、
空の画像レイヤを作成しておき、APIから取得した画像データをsrcで埋め込んでいる点です

今回使用したAPI 小学校区、中学校区 以外にも、
バイナリデータを返すAPIが幾つかあるようですので、それを地図に表示する場合にも参考になるかと思います
（用途地域・地価公示・地価調査・人口集中地区 など


# 気になった点
- javascript内からZENRIN Maps APIの検索APIを利用するために用意されたmap.requestAPI()は使えなかった
（レスポンスが画像データのAPIには使用できない
なので、XMLHttpRequestまたはfetchで実装する必要がありました  
  
- マウスホイールでズームを変更した際、操作完了のキャッチするイベントがない
zoom_changed、zoomstart、zoomend が用意されているが、
マウスホイールでズームを変更した際は操作中でもイベントが発生してしまいます
今回、地図操作後に再描画する処理を実装しましたが、
このイベントだとホイール操作中(操作を止めなくても)に大量にAPIリクエストしてしまいました