MonacaとNCMBで地図メモアプリを作る（その3: マーカー表示と一覧表示）

スマートフォンと地図は相性が良いです。スマホは持ち歩いて使うのが基本ですし、位置情報などの情報も取得できます。

今回はNCMBとMonacaを使って地図上にメモできる地図メモアプリを作ります。地図はOpenStreetMapのものを利用し、タップした場所にメモと写真を残しておけるアプリです。

[前回は地図表示とデータの登録を実装](https://qiita.com/goofmint/items/038f186f99bdab1aa088)しましたので、今回は地図上へのマーカー表示と一覧表示処理を実装していきます。

## コードについて

今回のコードは[map-note-monaca](https://github.com/NCMBMania/map-note-monaca) にアップロードしてあります。実装時の参考にしてください。

## 地図上へのマーカー表示

この処理は `www/map.html` にて実装します。地図が表示された際に、NCMBからメモ一覧を取得します。

```js
// ページが表示された後にノートをロードし、マーカーを追加します。
$on('page:afterin', async () => {
	// ノートをロードします。
	const notes = await loadNotes();
	// ストア内のノートをリセットします。
	$store.dispatch('resetNote');
	// 取得したノートを使って、マーカーを追加します。
	notes.forEach(addMarker);
});
```

`loadNotes` 関数は現在の中心点を利用して、周囲3キロにあるメモ一覧を取得する関数です。 `withinKilometers` メソッドにて、距離と位置情報を指定して絞り込み条件としています。

```js
// 中心座標の周辺3km以内のノートを取得する関数を定義します。
const loadNotes = async () => {
	const Note = ncmb.DataStore('Note');
	const center = map.getView().getCenter();
	const [lng, lat] = ol.proj.toLonLat(center);
	const geo = new ncmb.GeoPoint(lat, lng);
	return Note
		.withinKilometers("geo", geo, 3)
		.fetchAll();
};
```

### マーカーを表示する

マーカー表示の処理 `addMarker` はOpenLayerでの実装になります。繰り返し画像をダウンロードすることないようノートオブジェクトの `blob` プロパティにダウンロードしたデータ `file` を追加しています。また、そのノートオブジェクト自体、マーカーの `note` プロパティに追加しています。

```js
// ノートに基づいてマーカーを追加する関数を定義します。
const addMarker = async (note) => {
	// ノートの座標を取得します。
	const geo = note.get('geo');
	const coordinate = ol.proj.fromLonLat([geo.longitude, geo.latitude]);

	// ノートの画像をダウンロードします。
	const file = await ncmb.File.download(note.get('image'), 'blob');
	note.blob = file;
	$store.dispatch('addNote', note);

	// 画像のサイズを調整します。
	const image = await resizeImage(file, 40);
	const src = URL.createObjectURL(image);

	// マーカーのスタイルを設定します。
	const markerStyle = new ol.style.Style({
		image: new ol.style.Icon({
			anchor: [0, 0],
			size: [40, 40],
			src,
		}),
	});

	// マーカーを作成し、地図に追加します。
	const marker = new ol.Feature({
		geometry: new ol.geom.Point(coordinate),
	});
	marker.note = note;
	marker.setStyle(markerStyle);
	map.getLayers().getArray()[1].getSource().addFeature(marker);
}
```

OpenLayerではマーカーをCanvasタグ上に表示します。そのため、画像サイズを img タグのように柔軟に指定はできません。そこで `resizeImage` を用意して画像のリサイズを実行しています。 `resizeImage` 関数は `js/app.js` に定義しています。

```js
// 画像のリサイズを行う関数
const resizeImage = async (blob, maxWidth) => {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');
      const ratio = img.width / img.height;
      canvas.width = maxWidth;
      canvas.height = maxWidth / ratio;
      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
      canvas.toBlob((blob) => resolve(blob), 'image/jpeg');
    };
    img.src = URL.createObjectURL(blob);
  });
};
```

### マーカーをタップした際の処理

マーカーをタップした際の処理は、地図をタップしたときの処理の分岐です。

```js
// 地図をクリックしたときのイベントを設定します。
map.on('click', mapClick);
```

`mapClick` 関数は以下の通りです（紹介済み）。

```js
// 地図をクリックしたときの関数を定義します。クリックした場所にマーカーを追加したり、ツールチップを表示したりします。
const mapClick = (e) => {
	// クリックした場所のピクセル情報を取得します。
	const pixel = map.getEventPixel(e.originalEvent);

	// ピクセル情報から、マーカーが存在するかどうかを判定します。
	const target = map.forEachFeatureAtPixel(pixel, function(feature, layer) {
		return feature;
	});

	// クリックした場所にマーカーがあれば、そのマーカーに関連するツールチップを表示します。
	if (target) {
		return showTooltip(e.coordinate, target.note);
	}

	// マーカーがなく、すでに表示されているツールチップがあれば、そのツールチップを非表示にします。
	if ($('.marker').length > 0) {
		return hideTooltip();
	}

	// マーカーがなく、ツールチップも表示されていない場合、新たなマーカーを追加します。
	const [ lng, lat ] = ol.proj.toLonLat(e.coordinate);
	const path = `/note/${lat}/${lng}/`;
	$f7router.navigate(path);
};
```

マーカーがタップされた場合には `showTooltip` が呼ばれるので、ここでノートオブジェクトの内容に基づいてツールチップを表示します。

```js
// ツールチップを表示する関数を定義します。
const showTooltip = async (coordinate, note) => {
	hideTooltip(); // 既存のツールチップを非表示にします。

	// ツールチップに表示する画像を調整します。
	const image = await resizeImage(note.blob, 200);

	// ツールチップの要素を作成し、内容を設定します。
	const tooltip = document.createElement('div');
	tooltip.className = 'marker';
	tooltip.innerHTML = `
	<strong>${note.get('address')}付近のメモ</strong>
	<p>${note.get('text')}</p>
	<div>
		<img src="${URL.createObjectURL(image)}" width="100%" />
	</div>
	`;

	// ツールチップを地図に追加します。
	document.querySelector('#view-map').appendChild(tooltip);

	// ツールチップの位置を設定します。
	const tooltipOverlay = new ol.Overlay({
		element: tooltip,
		offset: [-20, -20],
	});
	map.addOverlay(tooltipOverlay);
	tooltipOverlay.setPosition(coordinate);
};
```

すでにツールチップが表示されている場合には `hideTooltip` を呼んで消しています。

```js
// ツールチップを非表示にする関数を定義します。
const hideTooltip = () => {
	const tooltip = document.querySelector('.marker');
	if (tooltip) {
		tooltip.parentNode.removeChild(tooltip);
	}
};
```

## メモの一覧表示

一覧画面 `pages/list.html` ではストアに入っているメモデータを一覧表示します。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="list media-list">
        <!-- メモの一覧を表示するためのリスト部分を定義 -->
        <ul>
          <!-- 各メモをリストアイテムとして表示する部分を定義 -->
          <!-- この部分では、JavaScriptのmap関数を使用してnotes.value内の各noteに対して以下のHTMLを生成しています -->
          ${ notes.value.map((note) => $h`
            <!-- 各メモを表現するリストアイテム部分 -->
            <li>
              <!-- メモの内容を表示する部分 -->
              <div class="item-content">
                <!-- もしメモに添付された画像(blob)が存在するならば、それを表示する -->
                ${ note.blob ?
                  $h`<div class="item-media"><img src="${URL.createObjectURL(note.blob)}" width="44" /></div>` :
                  ''
                }
                <!-- メモの内部情報を表示する部分 -->
                <div class="item-inner">
                  <!-- メモのタイトル部分と、メモの距離情報部分を表示する -->
                  <div class="item-title-row">
                    <!-- メモのタイトル部分 -->
                    <div class="item-title">${note.text}</div>
                    <!-- メモの距離情報部分 -->
                    <div class="item-after">${distance(note.geo.latitude, note.geo.longitude, coords.value.lat, coords.value.lng)}m</div>
                  </div>
                </div>
              </div>
            </li>
          `)}
        </ul>
      </div>
    </div>
  </div>
</template>
```

JavaScriptは以下の通りです。ストアからデータを取得して、それを一覧表示しています。

```html
<script>
  // このスクリプトはページの動作を定義します。
  // Framework7の仕組みを使って、propsという引数を通じて親コンポーネントからデータを受け取り、
  // $storeオブジェクトを利用しています。
  export default async function (props, {$store }) {
    // ストアからメモ(notes)と座標(coords)を取得します
    const { notes, coords } = $store.getters;
    // この関数の最後で、テンプレート部分をレンダリングします
    return $render;
  }
</script>
```

HTMLで利用している `distance` 関数は2つの位置情報から距離を出す関数です。 `js/app.js` にて定義しています。

```js
// 2点間の距離を求める関数（メートル単位で返す）
// https://qiita.com/kawanet/items/a2e111b17b8eb5ac859a 参照
const R = Math.PI / 180;
const distance = (lat1, lng1, lat2, lng2) => {
  lat1 *= R;
  lng1 *= R;
  lat2 *= R;
  lng2 *= R;
  return parseInt(6371 * Math.acos(Math.cos(lat1) * Math.cos(lat2) * Math.cos(lng2 - lng1) + Math.sin(lat1) * Math.sin(lat2)) * 1000);
};
```

## 今回利用したNCMBの機能

この地図メモアプリでは、NCMBの以下の機能を利用しました。

- データストア
  - データ登録
  - データ取得
- ファイルストア
  - アップロード
  - ダウンロード

NCMBには他にも認証、スクリプト、プッシュ通知などの機能があります。ぜひそれらの機能も利用してください。

## まとめ

今回はOpenLayerとMonacaを組み合わせて、位置情報を利用したメモアプリを作成しました。位置情報検索はNCMBの売り機能でもあるので、ぜひ利用してください。

[ドキュメント : 開発者向けドキュメント | ニフクラ mobile backend](https://mbaas.nifcloud.com/doc/current/#/JavaScript)