MonacaとNCMBで地図メモアプリを作る（その2: 地図の表示とメモの追加）


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

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

[前回の記事では画面の説明とSDKの導入までを進めました](https://qiita.com/goofmint/items/dd1a6932fb751220da95)ので、今回は地図表示とデータの登録を実装していきます。

## コードについて

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

## 地図を表示する

ここからはNCMBは関係なく、OpenLayerの話になります。まず必要なライブラリを `www/index.html` にて読み込みます。

```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@v7.3.0/ol.css">
<script src="https://cdn.jsdelivr.net/npm/ol@v7.3.0/dist/ol.js"></script>
```

### www/pages/map.htmlの処理

では画面を作成します。地図を表示するのは `#map` になります。

```html
<template>
  <!--
  この部分はHTMLテンプレートで、地図を表示するための<div>タグを定義しています。
  -->
  <div class="page" data-name="map">
    <div id="map"></div>
  </div>
</template>
<style>
  /* 
  この部分はCSSスタイルシートで、地図の表示スタイルを定義しています。
  地図の表示エリアを画面全体に広げています。
  */
  #map {
    width: 100%;
    height: 100%;
  }
</style>
```

ここからはJavaScriptのコードです。全体は以下のコードで囲みます（Framework7のお作法です）。

```html
<script>
  /* 
  この部分はJavaScriptで、地図の操作や表示に関する詳細な設定を行っています。
  */
  export default (props, { $f7route, $store, $on, $f7router }) => {
		// ここに実装していきます
    return $render; // 最終的にレンダリングを行います。
  };
</script>
```

ページがマウントされた際に地図を初期化する `initMap` 関数を呼び出します。

```js
// ページが表示されたときに地図を初期化します。
$on('page:mounted', initMap);
```

### initMapの実装

`initMap` ではOpenLayerを利用して地図を表示します。前半はOpenLayerの初期化設定で、後半では地図をタップした際のイベント設定と、中心が変わったときのストア更新処理を行っています。

```jsx
// 地図を初期化する関数を定義します。
const initMap = () => {
	// マーカーを表示するためのレイヤーを作成します。
	const markerLayer = new ol.layer.Vector({
		source: new ol.source.Vector(),
	});

	// 地図を作成し、地図とマーカーのレイヤーを設定します。また、初期の中心座標とズームレベルを設定します。
	map = new ol.Map({
		target: 'map',
		layers: [
				new ol.layer.Tile({
				source: new ol.source.OSM(),
			}),
			markerLayer,
		],
		view: new ol.View({
			center: ol.proj.fromLonLat([ lng, lat]), // 東京タワーの位置情報
			zoom: 14,
		})
	});
	
	// 地図をクリックしたときのイベントを設定します。
	map.on('click', mapClick);

	// 地図の中心が変わったときのイベントを設定します。
	const view = map.getView();
	view.on('change:center', (e) => {
		const [lng, lat] = ol.proj.toLonLat(view.getCenter());
		$store.dispatch('setCoords', { lat, lng });
	});
};
```

### 地図をタップした際の処理

地図をタップした際には `mapClick` 関数が呼ばれます。この関数では、地図上のマーカーがタップされたのか、それ以外の部分なのかを判定して処理分けしています。

```jsx
// 地図をクリックしたときの関数を定義します。クリックした場所にマーカーを追加したり、ツールチップを表示したりします。
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);
};
```

上記関数での `/note/${lat}/${lng}/` がノート画面への遷移になります。

## ノート画面での表示

ノート画面 `pages/note.html` は以下のようにフォームを表示しています。

```html
<template>
  <!-- ページ全体のレイアウトを定義する部分 -->
  <div class="page" data-name="product">
    
    <!-- ナビゲーションバーを定義する部分 -->
    <div class="navbar">
      <!-- ナビゲーションバーの背景を定義する部分 -->
      <div class="navbar-bg"></div>
      <!-- ナビゲーションバーの内側を定義する部分 -->
      <div class="navbar-inner sliding">
        <!-- ナビゲーションバーの左側を定義する部分。ここでは戻るボタンが設置されている -->
        <div class="left">
          <a href="#" class="link back">
            <i class="icon icon-back"></i>
            <span class="if-not-md">戻る</span>
          </a>
        </div>
        <!-- ナビゲーションバーのタイトルを定義する部分。ここでは新規メモと表示されている -->
        <div class="title">新規メモ</div>
      </div>
    </div>
    
    <!-- ページの主要なコンテンツを定義する部分 -->
    <div class="page-content">
      <!-- メモのタイトルを定義する部分。アドレス付近のメモと表示される -->
      <div class="block-title">${page.address}付近のメモ</div>
      <!-- メモの内容を入力する部分を定義するフォーム -->
      <div class="block block-strong">
        <!-- メモと画像を入力するためのフォーム -->
        <form class="note list" @submit="${submit}">
          <ul>
            <!-- メモ入力部分 -->
            <li>
              <div class="item-content item-input">
                <div class="item-inner">
                  <div class="item-title item-label">メモ</div>
                  <div class="item-input-wrap">
                    <textarea class="resizable"></textarea>
                  </div>
                </div>
              </div>
            </li>
            <!-- 画像選択部分 -->
            <li>
              <div class="item-content item-input">
                <div class="item-inner">
                  <div class="item-title item-label">画像</div>
                  <div class="item-input-wrap">
                    <!-- すでに選択されている画像があれば表示、なければ画像選択アイコンを表示 -->
                    ${page.image ?
                      $h`<img src="${page.image}" width="100%"  @click="${select}" />`
                      :
                      $h`<i class="f7-icons" @click="${select}">photo</i>`
                    }
                    <span class="image-wrap">
                      <!-- 画像ファイル選択のためのinput要素。デフォルトでは表示されていません -->
                      <input type="file" name="file" accept="image/*" @change="${change}" />
                    </span>
                  </div>
                </div>
              </div>
            </li>
            <!-- 保存ボタン -->
            <button class="button col" @click=${submit}>保存</button>
          </ul>
        </form>
      </div>
    </div>
  </div>
</template>

<!-- スタイルシート。画像ファイル選択のinput要素を非表示に設定しています -->
<style>
  .image-wrap {
    display: none;
  }
</style>
```

### JavaScriptの処理

まずJavaScriptはFramework7の作法に沿って定義します。位置情報である `lat` および `lng` は前の画面から渡されたデータです。

```html
<!-- スクリプト部分。ページの動作を定義しています -->
<script>
  // JavaScriptのコード。ここでページの動作を定義します
  export default (props, { $f7router, $f7route, $store, $on, $update }) => {
    // 座標情報を取得します
    const { lat, lng } = props;
    // pageオブジェクトを定義します。ここに入力情報が保存されます
    const page = {
      lat: parseFloat(lat),
      lng: parseFloat(lng),
      image: undefined,
    };

		// この中に処理を書きます

    // 描画を行います
    return $render;
  };
</script>
```

#### 逆ジオコーディングの実行

画面が表示（初期化）された際に、国土地理院APIを使って位置情報を住所に変換します。

```js
// ページが初期化されたときに呼ばれる関数を定義します
$on('pageInit', async () => {
	// 逆ジオコーディングで座標から住所情報を取得します
	page.address = await reverseGeoCoding(lat, lng);
	// 描画を更新します
	$update();
});
```

`reverseGeoCoding` 関数は以下のようになります。 `js/app.js` に定義しています。

```js
// 緯度と経度から地名を取得する関数
// https://memo.appri.me/programming/gsi-geocoding-api 参照
const reverseGeoCoding = async (lat, lng) => {
  const url = `https://mreversegeocoder.gsi.go.jp/reverse-geocoder/LonLatToAddress?lat=${lat}&lon=${lng}`;
  const res = await fetch(url);
  const json = await res.json();
  const params = GSI.MUNI_ARRAY[json.results.muniCd].split(',');
  return `${params[1]}${params[3]}${json.results.lv01Nm}`;
}
```

なお、 `GSI` という変数は国土地理院の用意している住所一覧です。これは `www/index.html` にて読み込みます。

```html
<!-- 初期化 -->
<script>
const GSI = {};
</script>
<script src="https://maps.gsi.go.jp/js/muni.js"></script>
```

### 写真選択時の処理

写真を選択する際には、まずカメラアイコンをタップします。これは非表示になっているファイル選択ダイアログをクリックするものです。

```js
// 画像選択部分がクリックされたときに呼ばれる関数を定義します
const select = () => {
	// 画像選択のinput要素をクリックします
	$('form.note input[type=file]').click();
};
```

そして写真が選択されたら、プレビューを表示します。

```js
// 画像が選択されたときに呼ばれる関数を定義します
const change = (e) => {
	// 選択されたファイルを取得します
	const file = e.target.files[0];
	// 選択された画像のURLを取得します
	page.image = URL.createObjectURL(file);
	// 描画を更新します
	$update();
}
```

なお、この時のURLは `blob://` となっており、デフォルトのCSP（コンテンツセキュリティポリシー）では表示できません。 `www/index.html` に以下を追加します。

```html
<!-- 末尾に blob: を追加 -->
<meta http-equiv="Content-Security-Policy" content="default-src * 'self' 'unsafe-inline' 'unsafe-eval' data: gap: content: blob:">
```

### ノートを保存

入力が終わったら、保存処理を実行します。選択されている位置情報はNCMBの位置情報オブジェクトに変換します。そして住所、メモと一緒に保存します。

```js
// 保存ボタンが押されたときに呼ばれる関数を定義します
const submit = async (e) => {
	// フォームのデフォルトの送信動作をキャンセルします
	e.preventDefault();
	// Noteオブジェクトを作成します
	const Note = ncmb.DataStore('Note');
	const note = new Note;
	// ジオポイントを作成します
	const geo = new ncmb.GeoPoint(page.lat, page.lng);
	// メモに座標、住所、テキストをセットします
	note
		.set('geo', geo)
		.set('address', page.address)
		.set('text', $('form.note textarea').val());
	// 画像が選択されていれば画像もセットします
	if (page.image) {
		// 選択された画像のデータをBlob形式に変換します
		const blob = await toBlob(page.image);
		// ファイル名を一意な名前に変換します
		const ext = blob.type.split('/')[1];
		const name = Math.random().toString(32).substring(2);
		const fileName = `${name}.${ext}`;
		// ファイルをアップロードします
		await ncmb.File.upload(fileName, blob);
		// ファイル名をメモにセットします
		note.set('image', fileName);
	}
	// メモを保存します
	await note.save();
	// 前のページに戻ります
	$f7router.back();
}
```

`toBlob` は選択されている画像データ（blob://形式）をBlobに戻す関数です。

```js
// 画像のURLをBlob形式に変換する関数を定義します
const toBlob = async (uri) => {
	// 画像のURLからデータを取得します
	const res = await fetch(uri);
	// データをBlob形式に変換します
	const blob = await res.blob();
	return blob;
};
```

これでメモの保存処理が完成しました。

## まとめ

今回は地図の表示とメモの保存処理を実装しました。次回は[地図上へのマーカー表示と一覧表示処理](https://qiita.com/goofmint/items/314bc03a2c656acebbc4)を実装します。