APIレスポンスのデータを3Dマップ上に表示する

この記事は [Postman Advent Calendar 2024](https://qiita.com/advent-calendar/2024/postman) の9日目の記事です。

# Postman のビジュアライズ機能と制限事項

Postman には、送信したリクエストのレスポンスを HTML テンプレートと JavaScript コードを使って可視化することができる[ビジュアライズ機能](https://learning.postman.com/docs/sending-requests/response-data/visualizer/)があります。ビジュアライズ機能については、昨年に次の記事でも紹介しました。

https://qiita.com/nagix/items/d38795f5851da4292f06

ビジュアライズ機能では、外部の JavaScript やスタイルシートファイルも読み込んで使えるため、基本的にはブラウザで動作できるものは何でも動作します。私は普段 [Mapbox GL JS](https://www.mapbox.jp/mapbox-gljs) のようなウェブ 3D マップライブラリをよく扱うので、ビジュアライズ機能を使って API レスポンスを 3D マップ上に可視化してみたい、と前から思っていました。

しかし、ビジュアライズ機能ではユーザー生成コンテンツを安全に表示する仕組み上、コンテンツセキュリティーポリシー (CSP) 制限付きの iframe サンドボックスでコンテンツ環境を分離しており、`blob:` ソースのコンテンツが使えない、`eval()` が使えない、ウェブワーカーや IndexedDB が使えない、などの制限があります。

Mapbox GL JS は `blob:` ソースやウェブワーカーを使用してるため、これでは Postman ビジュアライズ機能では使えません。ちなみにウェブ 3D マップライブラリでは、UI 操作に性能上の影響がないようにマップデータのローディングをウェブワーカーで実行する形が一般的なので、Google Maps や CesiumJS も同じ理由で使えません。

そんな中、この件について色々な回避策を試した結果を、先日 [CSP で制限されたページで Mapbox GL JS を使うには](https://nagix.hatenablog.com/entry/2024/12/05/162730)というブログ記事にまとめました。記事中、特殊な例としてウェブワーカーの利用ができない環境について取り上げましたが、Postman のビジュアライズ機能の環境がまさにそれです。

というわけで、以下では Postman に埋め込んだ Mapbox GL JS の 3D マップ上に、API レスポンスのデータを可視化する具体的な手順を見ていきましょう。

# ホテルの空室情報を 3D マップ上に表示する

ここでは、[楽天トラベル空室検索 API](https://webservice.rakuten.co.jp/documentation/vacant-hotel-search) で取得したホテル情報をマップ上に表示することを目指します。あらかじめ楽天アカウントで[楽天ウェブサービス](https://webservice.rakuten.co.jp)にログインし、新規アプリ登録を行なって「アプリ ID」を取得しておきましょう。

また、Mapbox アクセストークンも必要ですので、[Mapbox アカウント](https://account.mapbox.com)にて Mapbox アカウントを作成して、アクセストークンを作成しておきます。

本記事で扱う API リクエスト、およびそれを含む [Postman コレクションはこちら](https://www.postman.com/postman/workspace/postman-japan/collection/26169716-9bb29177-66e0-4ace-a3fa-0311411c80f1?action=share&creator=26169716)です。すぐに試してみたい方は、このコレクションを自分のワークスペースにフォークして、楽天アプリ ID、Mapbox アクセストークンを指定すれば動かすことができます。

それでは、このコレクションの内容を簡単に説明していきます。

**コレクション変数**

コレクションで定義されている変数です。`rakuten-appid`、`mapbox-token` の現在値は空になっているので、自分で取得してきた値を入れて、保存してください。`mapbox-*-url` の値は後述のスクリプトで使われます。

変数 | 値
-- | --
`rakuten-appid` | 数字で構成される楽天アプリ ID
`mapbox-token` | `pk.` で始まる Mapbox アクセストークン
`mapbox-css-url` | `https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl.css`
`mapbox-js-url` | `https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp.js`
`mapbox-worker-url` | `https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp-worker.js`

**エンドポイント**

[楽天トラベル＋Mapbox](https://www.postman.com/postman/postman-japan/request/dphvhcv/mapbox?tab=params) という API リクエストを見てください。ホテルの空室情報を検索するエンドポイントです。

```
GET https://app.rakuten.co.jp/services/api/Travel/VacantHotelSearch/20170426
```

**クエリパラメーター**

検索条件については[楽天トラベル空室検索 API](https://webservice.rakuten.co.jp/documentation/vacant-hotel-search) をご覧いただきたいと思いますが、ここでは東京駅付近から半径1km圏内に絞って空室を検索しています。チェックイン・チェックアウト日を指定していませんが、デフォルトで当日チェックイン、翌日チェックアウトの指定になります。

キー | 値
-- | --
`format` | `json`
`latitude` | `35.6814`
`longitude` | `139.7670`
`datumType` | `1`
`searchRadius` | `1`
`applicationId` | `{{rakuten-appid}}`
`hotelThumbnailSize` | `2`

**Pre-request スクリプト**

`mapbox-worker-url` から取得したウェブワーカースクリプトを変数 `worker-script` に設定しています。

```js
pm.sendRequest(pm.collectionVariables.get('mapbox-worker-url')).then(response => {
    pm.variables.set('worker-script', response.text());
});
```

**Post-response スクリプト**

HTML テンプレート部分は、[CSP で制限されたページで Mapbox GL JS を使うには](https://nagix.hatenablog.com/entry/2024/12/05/162730)の記事に準じたもので、擬似 `Worker` クラスを作って本来は動かせないはずのウェブワーカーコードをメインスレッドで動かすテクニックを使っています。これに加えて、テンプレート部の最後で各ホテルのサムネイル画像、名前、宿泊料金を表示するポップアップを作成しているのと、Post-response スクリプトの最後でレスポンスのデータから必要なデータを取り出して、Visualizer を呼び出しているのが新しい部分です。

```js
const template = `
<link href="${pm.collectionVariables.get('mapbox-css-url')}" rel="stylesheet">
<script src="${pm.collectionVariables.get('mapbox-js-url')}"></script>
<div id="map" style="width: 100%; height: 100%;"></div>
<script>
mapboxgl.workerClass = (function(window) {
  const WW_CONTEXT_WHITELIST = [
    'createImageBitmap', 'fetch', 'ImageBitmap', 'ImageData',
    'OffscreenCanvas', 'Request', 'AbortController'
  ];

  return function() {
    const me = this;
    let worker_context;

    // Allow main thread to specify event listeners
    const ui_listeners = {};
    this.addEventListener = (event_name, fn) => {
      // listen for events from worker thread
      if (!ui_listeners[event_name])
        ui_listeners[event_name] = [];
      ui_listeners[event_name].push(fn);
    };

    // onmessage handler
    this.addEventListener('message', e => {
      if (typeof me.onmessage !== 'undefined') {
        me.onmessage(e);
      }
    });

    function WorkerGlobalScope() {}

    /**** Worker context accessible to worker *****/
    function WorkerContext() {
      const worker_listeners = {};
      this.addEventListener = (event_name, fn) => {
        // listen for events from UI thread
        if (!worker_listeners[event_name])
          worker_listeners[event_name] = [];
        worker_listeners[event_name].push(fn);
      };

      // onmessage handler
      this.addEventListener('message', e => {
        if (typeof worker_context.onmessage !== 'undefined') {
          try {
            worker_context.onmessage(e);
          } catch (error) {
            triggerEvent(ui_listeners, 'error', error, true);
          }
        }
      });

      this.postMessage = msg => {
        triggerEvent(ui_listeners, 'message', msg);
      };

      this.__processPostMessage = msg => {
        triggerEvent(worker_listeners, 'message', msg);
      };

      this.close = () => {};

      // window context
      for (const p of WW_CONTEXT_WHITELIST) {
        if (typeof window[p] === 'function' && !window[p].prototype) {
          this[p] = window[p].bind(window);
        } else {
          this[p] = window[p];
        }
      }
      this.WorkerGlobalScope = WorkerGlobalScope;
    }
    WorkerContext.prototype = new WorkerGlobalScope();
    worker_context = new WorkerContext();

    this.postMessage = msg => {
      worker_context.__processPostMessage(msg);
    };

    this.terminate = () => {};

    function triggerEvent(listeners_map, event_name, event_data, no_wrapping) {
      const event_obj = no_wrapping ? event_data : {data: event_data};

      if (!listeners_map[event_name]) return;
      for (const listener of listeners_map[event_name]) {
        listener(event_obj);
      }
    }

    const mask = {};

    // worker context
    for (const p in worker_context) {
      mask[p] = worker_context[p];
    }
    // set self context
    mask['self'] = worker_context;

    with(mask) {
      ${pm.variables.get('worker-script')}
    }
  }
})(window);

mapboxgl.accessToken = '${pm.collectionVariables.get('mapbox-token')}';

const map = new mapboxgl.Map({
  container: 'map',
  center: [139.7670, 35.6814],
  zoom: 16,
  pitch: 60
});
pm.getData((error, {hotels}) => {
    for (const hotel of hotels) {
        const html = [
            \`<div style="width: 90px">\`,
            \`<img src="\${hotel.thumbnail}"><br>\`,
            \`\${hotel.name}<br>\`,
            \`<a href="\${hotel.plans}" style="color: red; font-weight: bold">\`,
            \`\${hotel.price.toLocaleString()}\\u5186\\u301C\`,
            \`</a>\`,
            \`</div>\`,
        ].join('');
        const popup = new mapboxgl.Popup()
            .setLngLat(hotel.lngLat)
            .setHTML(html)
            .addTo(map);
    }
});
</script>
`;

const hotels = pm.response.json().hotels.map(item => {
    const hotelInfo = item.hotel[0].hotelBasicInfo;
    return {
        id: hotelInfo.hotelNo,
        name: hotelInfo.hotelName,
        plans: hotelInfo.planListUrl,
        lngLat: [hotelInfo.longitude, hotelInfo.latitude],
        thumbnail: hotelInfo.hotelThumbnailUrl,
        price: item.hotel[1].roomInfo[1].dailyCharge.total
    }
});
pm.visualizer.set(template, {hotels});
```
ということで、仕上がりはこんな感じです。マップをスクロールして、空室のあるホテルの位置と宿泊料金を確認することができます！

![Screenshot 2024-12-09 at 10.56.05 PM.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32610/d8dc6b62-cd6a-0a50-b84b-7119fdc14f81.png)

# ホテルの空室情報を Mini Tokyo 3D 上に表示する

Mapbox GL JS でこれができるということは、Mapbox GL JS をベースにしている [Mini Tokyo 3D](https://minitokyo3d.com) でもできるということで、[こちらの API リクエスト](https://www.postman.com/postman/postman-japan/request/i4ckwsu/mini-tokyo-3d?action=share&creator=26169716&tab=scripts)も作ってみました。

Mini Tokyo 3D のマップ上では、リアルタイムで列車や航空機が動くので、リアルタイムのホテル検索もリアリティが増しますね！

![Screenshot 2024-12-09 at 11.25.01 PM.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32610/87f5c9da-6e58-6ddb-1a21-420b3125c661.png)