【ハンズオン資料】Monaca × NCMBで位置情報検索アプリを作る

NCMBにはいくつかの機能がありますが、アプリと親和性の高い機能としてはプッシュ通知と位置情報機能が挙げられます。デバイスから位置情報を取得し、それをマッピングしたり、自分の今いる場所に近い情報を得たりする際にも位置情報検索が利用できます。

今回はMonacaとNCMBで位置情報検索を行うアプリを作成してみます。

## ベースコード

下記のURLをMonaca IDEにてインポートしてください。

```
https://github.com/NCMBMania/handson_monaca_map_app/archive/refs/heads/main.zip
```

## 利用している技術、ライブラリ

このアプリで利用している技術やライブラリは次の通りです。

- [Framework7](https://framework7.io/)
- [ニフクラmobile backend](https://mbaas.nifcloud.com/)
- [Mapbox](https://www.mapbox.com/)

今回はMonacaでOnsen UIの最小限のテンプレートを選択しています。

![](https://press.monaca.io/wp-content/uploads/2020/11/daily_app_13.png)

## 利用するNCMBの機能

- データストア
    - Stationクラス
        - データ登録
        - データ削除
        - データ検索

## スキーマについて

今回はStationというクラスを用意します。駅名と、その位置情報が入ったクラスです。

![](http://www.plantuml.com/plantuml/svg/SoWkIImgAStDuKhEIImkLWWkIIp9pCzJgEPI0BABClFI5V8ISrE1FFVK_83ypBmIXVJKVAvQBeVKl1IWDW00)

## 仕様について

今回は次の2画面で構成されたアプリになります。

### インポート画面

あらかじめ用意してある山手線の各駅の名前と位置情報が入ったJSONファイルを取り込みます。

### 地図画面

地図画面ではMapbox（Open Street Map）を表示します。タップした状態に合わせて、2つの位置情報検索を実行します。

### 距離を指定した検索

一度タップするとマーカーが表示されて、その付近（半径2km）にある山手線の駅にピンが立ちます。

### 範囲の指定した検索

もう一度タップすると、2つのマーカーの間にある山手線の駅にピンが立ちます。

### データの扱い

今回はデモアプリということもありますので、駅データは誰でも読み書き可能としています。実際の運用時においては、適切なアクセスコントロールを行ってください。

## Mapboxについて

[Mapbox](https://www.mapbox.com/)はOpenStreetMapを使うために必要なサービスです。無料でアカウントの作成とプロジェクト作成ができます。利用量によっては有料になるサービスです。Googleマップとの違いとしては、自分独自のコンテンツを追加してオリジナルの地図を作ったり、公開できるのが特徴となっています。

最近Googleマップは決済設定が必須になっていたり、AppleのMapKitはiOS向けといった状況です。そこでマルチデバイスで、かつフリーでも利用できるMapboxを採用しています。

## インポート画面について

インポート画面 `pages/import.html` は設定タブをタップした際に表示される画面です。インポートを実行するボタンと、ログを表示するリストがあります。

```html
<template>
  <div class="page">
    <div class="navbar">
      <div class="navbar-bg"></div>
      <div class="navbar-inner sliding">
        <div class="title">インポート</div>
      </div>
    </div>
    <div class="page-content">
      <div class="block">
        <div class="row">
          <button class="button col" @click=${execImport}>インポート実行</button>
        </div>
      </div>
      <ul id="log">
      </ul>
    </div>
  </div>
</template>
```

## NCMBの初期化

NCMBのJavaScript SDKは `js/app.js` にて初期化しておきます。

```js
// 書き換えてください
const applicationKey = 'YOUR_APPLICATION_KEY';
const clientKey = 'YOUR_CLIENT_KEY';
const ncmb = new NCMB(applicationKey, clientKey);
```

## インポートの実行

インポートはボタンをタップした際に呼ばれる `execImport` で実行されます。

```js
// インポートの実行
const execImport = async () => {
}
```

まず最初に既存のデータを削除します。これは複数回、何度でもインポート可能とするためです。Stationクラスはデータ登録時にも利用するので関数外のスコープで定義しています。

```js
// 駅クラス（DBでいうテーブル相当）
const Station = ncmb.DataStore('Station');

// 既存データを削除する処理
const cleanUp = async () => {
  // 既存データを検索
  const stations = await Station
    .limit(100)
    .fetchAll();
  // 検索結果をすべて削除
  return Promise.all(stations.map(s => s.delete()));
}

// インポートの実行
const execImport = async () => {
  // 既存データの削除
  await cleanUp();
  // 省略
}
```

削除処理はデータを1件ずつ削除する形になります。この削除処理（deleteメソッド）はPromiseを返しますので、map関数で配列化し、まとめてPromise.allで処理結果を待っています。たとえばこれをfor文の中でawaitを使う方式にすると、処理が直列になるので時間が大幅にかかってしまうでしょう。

```js
// 良くない例
for (const s of stations) {
  await s.delete();
}
```

既存データを削除したら、山手線の各駅に関する情報が入ったJSONファイルを読み込みます。

```js
// 駅情報のJSONを取得
const res = await fetch('./assets/json/yamanote.json');
const stations = await res.json();
```

yamanote.jsonの内容は次のようになっています。

```json
[
  {
    "name": "東京",
    "latitude": "35.681382",
    "longitude": "139.76608399999998"
  },
  {
    "name": "有楽町",
    "latitude": "35.675069",
    "longitude": "139.763328"
  },
  :
]
```

このJSONファイルを順番に取り込んでいきます。

```js
// JSONの内容をNCMBに反映する
for (const params of stations) {
  // この中で処理
}
```

まずStationクラスのインスタンス（DBでいう行相当）を作成します。

```js
// 駅クラスのインスタンス（DBでいう行相当）を用意
const s = new Station;
```

次に位置情報オブジェクトを作成します。前が緯度、後が経度を引数とします。

```js
// 位置情報オブジェクトを作成
const geo = new ncmb.GeoPoint(params.latitude, params.longitude);
```

後は駅名と位置情報をセットして保存します。

```js
// 位置情報オブジェクトと駅名をセットして保存
await s
  .set('name', params.name)
  .set('geo', geo)
  .save();
```

保存が終わったらログを出力しています。今回はログを出力したいので、保存処理を await にしています。本来はループ処理の中で使うべきではないでしょう。

```js
// ログに書き出し
$('#log').append(`
  <li>${s.get('name')}を追加しました</li>
`);
```

これで完成です。import.html の中のJavaScriptは次のようになります。

**記述してください**

```js
export default async function (props, {$f7, $f7router, $on }) {
  // 駅クラス（DBでいうテーブル相当） ここを書く
  const Station = ncmb.DataStore('Station');

  // 既存データを削除する処理
  const cleanUp = async () => {
    // 既存データを検索（ここを書く）
    const stations = await Station
      .limit(100)
      .fetchAll();
    // 検索結果をすべて削除
    return await Promise.all(stations.map(s => s.delete()));
  }

  // インポートの実行
  const execImport = async () => {
    // 既存データの削除
    await cleanUp();
    // 駅情報のJSONを取得
    const res = await fetch('./assets/json/yamanote.json');
    const stations = await res.json();
    // JSONの内容をNCMBに反映する
    for (const params of stations) {
      // 駅クラスのインスタンス（DBでいう行相当）を用意（ここを書く）
      const s = new Station;
      // 位置情報オブジェクトを作成（ここを書く）
      const geo = new ncmb.GeoPoint(params.latitude, params.longitude);
      // 位置情報オブジェクトと駅名をセットして保存（ここを書く）
      await s
        .set('name', params.name)
        .set('geo', geo)
        .save();
      // ログに書き出し
      $('#log').append(`
        <li>${s.get('name')}を追加しました</li>
      `);
    }
    $f7.dialog.alert('インポート完了しました');
  }
  return $render;
}
```

## 処理の実行

インポートボタンを押すと、取り込まれた駅名がリスト表示されていきます。そして最後にアラートが出て完了です。

## 管理画面での表示

管理画面のデータストアにStationクラスが追加され、その中に駅名が並んでいるのが確認できるはずです。

## 地図画面について

地図画面 `pages/map.html` はMapboxを表示する画面です。HTMLはMapbox用に地図用のDOMを配置します。

```html
<template>
  <div class="page">
    <div class="navbar">
      <div class="navbar-bg"></div>
      <div class="navbar-inner sliding">
        <div class="title">マップ</div>
        <div class="right">
          <a href="#" @click=${clear}><i class="f7-icons">trash</i></a>
        </div>
        </div>
    </div>
    <div class="page-content">
      <div id="map"></div>
    </div>
  </div>
</template>
<style>
  #map {
      width: 100%;
      height: 100%;
  }
</style>
```

次にJavaScriptでは、各関数で共有する変数を定義しておきます。

```html
<!-- 記述済み -->
<script>
  export default async function (props, {$f7, $f7router, $on }) {
    // タップしたマーカーが入る
    const markers = [];
    // 駅のマーカーが入る
    const stationsMarkers = [];
    // MapBoxオブジェクト
    let map;
    // 省略
  }
</script>
```

## 画面表示時にMapboxを初期化

map.html を表示した際にMapboxを初期化します。その際にはアクセストークンが必要なので、これを `js/app.js` にて定義しておきます。

```js
// 書き換えてください
const mapboxAccessToken = 'YOUR_MAPBOX_ACCESS_TOKEN';
```

そして画面表示時のイベントで初期化処理を行います。

```js
// 記述済み
// 画面表示前に実行されるイベント
$on('pageBeforeIn', async (e, page) => {
  // MapBoxを用意
  mapboxgl.accessToken = mapboxAccessToken;
  map = new mapboxgl.Map({
    container: 'map',
    style: 'mapbox://styles/mapbox/streets-v11',
    center: [139.7454329, 35.6585805], // 東京タワーの位置情報
    zoom: 14
  });
  // 省略
});
```

初期化に続けて、地図をタップした際のイベントを定義します。これはマーカーを立てる処理に加えて、1つのマーカーであれば距離検索、2つのマーカーであれば範囲検索を行う関数を実行します。

```js
// 記述済み
// 地図をクリックした場合
map.on('click', async e => {
  addMarker(e.lngLat.lng, e.lngLat.lat);
  if (markers.length === 1) {
    // マーカーを中心に5km以内の駅をリストアップ
    searchMapPoint();
  } else {
    // 2つのマーカーに含まれる駅をリストアップ
    searchMapSquare();
  }
});
```

## マーカーの追加処理

マーカーの追加処理 `addMarker` は次の通りです。ここではMapboxのAPIを実行するだけです。すでに2つのマーカーが立っている場合は、1つ目のマーカーを削除します。

```js
// 記述済み
// マーカーを立てる処理
const addMarker = (lng, lat) => {
  // すでに2つのマーカーが立っている場合は、前のものを削除する
  if (markers.length == 2) {
    markers[0].remove();
    markers.shift();
  }
  // マーカーを立てる
  const marker = new mapboxgl.Marker()
    .setLngLat([lng, lat])
    .addTo(map);
  markers.push(marker);
}
```

## 距離検索

マーカーが一つだった場合には距離検索を実行します。マーカーを中心として、半径2km以内にある駅を検索します。 これは `withinKilometers` メソッドで実行し、最後の2という数字が2kmを意味しています。

**記述してください**

```js
// 1つのマーカーを中心に、2km以内の駅を検索する処理
const searchMapPoint = async () => {
  // すでにある駅のマーカーを削除
  clearStations();
  // 1つ目のマーカーの位置情報を取得
  const {lng, lat} = markers[0]._lngLat;
  // NCMBの位置情報オブジェクト作成（ここを書く）
  const geo = new ncmb.GeoPoint(lat, lng);
  // 検索するクラス（DBでいうテーブル相当）（ここを書く）
  const Station = ncmb.DataStore('Station');
  // 検索条件を指定（ここを書く）
  const stations = await Station
    .withinKilometers('geo', geo, 2) // 2km以内のデータを検索
    .limit(50)
    .fetchAll();
  // 検索結果をマーカーとして地図上に表示
  addStationMarkers(stations);
}
```

検索結果で取得できた駅の一覧を `addStationMarkers` にて地図上に描画します。これはマーカーを立てる時と同様、MapboxのAPIを実行しているだけです。

```js
// 記述済み
// 検索結果の駅一覧を地図上に表示
const addStationMarkers = (stations) => {
  for (const station of stations) {
    const marker = new mapboxgl.Marker({color: '#f00'})
      .setLngLat([station.geo.longitude, station.geo.latitude])
      .addTo(map);
    stationsMarkers.push(marker)
  }
}
```

検索実行前には、すでにある駅のマーカーを消す `clearStations` 関数を実行しています。

```js
// 記述済み
// 駅のマーカーを削除する処理
const clearStations = () => {
  for (const marker of stationsMarkers) {
    marker.remove();
  }
  stationsMarkers.splice(0, stationsMarkers.length);
}
```

## 範囲を検索する

マーカーが二つだった場合には、2つのマーカー内にある駅を検索して地図上に描画します。こちらは `withinSquare` メソッドを2つのGeoPointオブジェクトとともに実行するのがポイントです。

**記述してください**

```js
// 2つのマーカーに含まれている駅を検索する処理
const searchMapSquare = async () => {
  // すでにある駅のマーカーを削除
  clearStations();
  // マーカーをNCMBの位置情報オブジェクトにする
  const geos = [];
  for (const marker of markers) {
    const {lng, lat} = marker._lngLat;
    geos.push(new ncmb.GeoPoint(lat, lng));
  }
  // 検索するクラス（DBでいうテーブル相当）（ここを書く）
  const Station = ncmb.DataStore('Station');
  // 検索条件を指定（ここを書く）
  const stations = await Station
    .withinSquare('geo', geos[0], geos[1]) // 2つの位置情報で検索
    .limit(50)
    .fetchAll();
  // 検索結果をマーカーとして地図上に表示
  addStationMarkers(stations);
}
```

すでにマーカーが2つ立っている場合、1つに戻すことができないので、右上にゴミ箱アイコンを配置し、そのイベントとしてマーカーを削除する `clear` 関数を実行しています。

```js
// 記述済み
// マーカーを削除する
const clear = () => {
  for (const marker of markers) {
    marker.remove();
  }
  markers.splice(0, 2);
  // 駅のマーカーも削除
  clearStations();
}
```


## まとめ

これで地図を用いた位置情報検索アプリの完成です。今回は元データを用意しましたが、実際のアプリでは自分たちで好きに追加できるようにしても良いでしょう。データをあらかじめ登録する方式であれば、[‎つくばの公園 on the App Store](https://apps.apple.com/gb/app/id1499601524)のようなアプリを作る際にも利用できるはずです。