簡易GPSメモPWA『めもいち』をvue-cli で作ってみた話

　たまに地図上にメモを残せないかな？と考えていたのですが、Googleマップはちょっと敷居高いし、何か良い方法はないかな？と思って、「[めもいち](https://findit.horornis.com/)」と言うPWAサイトを自作してみたお話です。（ソースコードは[GitHubリポジトリ](https://github.com/mishikawa-Horornis/Simple-GPS-Memo)を参照のこと。

![Screenshot_20191203-093629.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359920/103a3bd9-d71b-1172-dd09-ee75f43c2b00.png)

# 制作の動機
　もともと携帯電話の基地局を歩いて探すことをしていて（かなりマニアック）GoogleMapを使ってたのですが、いちいちその場でサイト開いて…とかめんどくさい。もっと気楽にマーキングできないか？と考えて、とりあえず個人的メモをOpenStreetMapに書き込もうと考えました。偶然 `leaflet.js` ってライブラリを見つけたのもありました。

# そもそも独自性はあるのか？
　作る前に、代用できる物ってないのかな？って考えたんですよね。一般のGPSロガーとか、スマホのカメラの位置情報と地図の関連付けとか別にスマホがあれば使えるし… 言った先での写真とかなら `instagram` とか `Swarm(Foursquare)` とか考えましたが、パパッとメモするのにカメラ立ち上げてシャッター音ならして逆に問題大ありだろう…と思って、やっぱり作るしか…となりました。

　あと、個人の位置をクラウドに保存するのもプライバシーの観点からどうか？と思って、結局ローカルストレージに格納することにしました。ローカルストレージなのでさすがに写真撮影はバッサリ切り落としました。

# 制作するにあたって
　Vue.jsでPWAは前回の「[まねかん](https://horornis.com/mc/)」である程度はやってたのですが、どうせなら `Webpack` とか`Workbox` とか使いたいって言う頭があったので、とりあえず `Vue-cli` で作成することにしました。PWAにもするのでWorkboxも使いますが、サーバはレンタルサーバーなので、サーバー側ではNode.js使えない…。（これは仕方ない）

# テンプレート
　制作環境を構築するにあたって参考にした記事は、下記のページです。
・ [Vue.jsでPWAアプリを作る](https://qiita.com/chohas/items/c6ab829bc6b692ff23d5)
・ [Vueのプロジェクトでworkboxを使ってみる。workboxについて説明してみる](https://qiita.com/sachiko-kame/items/e0cb84f5ccd1e6914597)
・ [webpackでビルドする前にeslintで.vueと.jsの構文チェックをする | webpack4.x+babel7+vue.js 2.x 環境構築 2019年3月版 ステップ0004](http://motomichi-works.hatenablog.com/entry/2019/03/17/011517)
・ [Vue で地図を表示する無料で最短の道](https://qiita.com/Satachito/items/929a63e9b266f6db7958)
　素材作成のために `Shade13` と `Adobe Photoshop CC` も使ってます。

# 地図を表示させる

地図を初期化しなければいけないので、 `mounted()` に地図を表示させます。

```javascript:leaflet.vue（抜粋）
<template>
  <div id="leaflet-vue" />
</template>
〜〜中略〜〜
export default {
  props: {
    geoList: {
      type: Array,
      default: null,
    },
    selected: {
      type: String,
      default: null,
    },
  },
  data () {
    return {
      leafletMap: null,
      markers: [],
      LeyerGroup: null,
    };
  },
  watch: {
    geoList (newVal, oldVal) {
      this.geoList = newVal;
      this.GeoMapRender();
    },
  },
  mounted () {
    // マップを表示させる
    this.leafletMap = L.map('leaflet-vue', {
      center: L.latLng(34.77530283508074, 138.01500141620636),
      zoom: 4,
      layers: this.points,
    }).addLayer(
      L.tileLayer(
        'https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token=pk.(mapboxのトークン)',
        {
          maxZoom: 18,
          attribution:
            'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, ' +
            '<a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, ' +
            'Imagery © <a href="https://www.mapbox.com/">Mapbox</a>',
          id: 'mapbox.streets',
        }
      )
    );
  },
〜〜中略〜〜
```
これはそんなに難しくないです。ほとんどleafletのサンプル通り。

# マーカーを描画する

```javascript:leaflet.vue（抜粋）

〜〜中略〜〜
  methods: {
    GeoMapRender () {
      // マーカーのレイヤーグループがあれば削除
      if (this.layerGroup) {
        this.leafletMap.removeLayer(this.layerGroup);
      }
      this.markers = [];
      this.leafletMap.attributionControl.setPrefix(false);

      // おまじない
      const _self = this;

      // マーカーの数だけループ
      for (const item of this.geoList) {

        // マーカーを追加する。
        const marker = L.marker([item.latitude, item.longitude], {
          icon: item.marker_type === 'thumbtack' ? iconsThumbtack[item.color] : iconsNeedle[item.color],
          draggable: 'true',
          id: item.id,
        })
          .bindPopup(
            `${item.memo && item.memo.length ? item.memo.replace(/\n/g, '<br />') : ''}
              <p class="font-italic">at ${moment(item.time).format('YYYY.MM.DD HH:mm:ss')}`
          )
          .openPopup();

        // マーカーが移動されたら移動されたマーカーの緯度経度を親に返す
        marker.on('dragend', function () {
          const position = this.getLatLng();
          const index = this.options.id;
          const geoItem = _self.geoList.find(v => v.id === index);
          if (geoItem) {
            geoItem.latitude = position.lat;
            geoItem.longitude = position.lng;
            _self.$emit('onmoveditem', geoItem);
          }
        });

        // マーカーをタップされたら、マーカーのIDを親に返す
        marker.on('click', function () {
          const index = this.options.id;
          _self.$emit('onselectitem', index);
        });
        this.markers.push(marker);
      }

      // マーカーをレイヤーグループに追加してマップに重ねる
      this.layerGroup = L.layerGroup(this.markers);
      this.layerGroup.addTo(this.leafletMap);

      // 最後に追加したマーカーをマップの中心点にする。
      if (this.geoList.length > 0) {
        const lastGeo = Array.from(this.geoList).slice(-1);
        this.leafletMap.setView([lastGeo[0].latitude, lastGeo[0].longitude], 15);
      }
    },
  },
〜〜中略〜〜
```

　マーカーは更新されるとマップ上からマーカー用のレイヤーを削除して再描画させます。（こうしないとうまく更新できなかった）
　マーカーの管理はもっとちゃんとしないとダメかなぁ？とか思いましたが私の頭ではうまく思いつかなかったです。

# メイン画面

```javascript:App.vue（抜粋）
<template>
  <div id="app">
    <leaflet-vue
      :geo-list="geoList"
      @onmoveditem="onMovedItem($event)"
      @onselectitem="onSelectItem($event)"
    />
    <div id="form">
      <vm-status-indicator
        pulse
        :color="statusMode"
        class="indicator"
      >
        {{ statusMessage }}
      </vm-status-indicator>
      <vm-status-indicator
        pulse
        :color="errorLevel"
        class="indicator"
      >
        {{ errorMessage }}
      </vm-status-indicator>
      <div>
        <select
          v-model="selectedGeoListItem.marker_type"
          @change="onChange"
        >
          <option
            disabled
            value=""
          >
            マーカーを選択
          </option>
          <option
            v-for="item of markerTypes"
            :key="item.id"
            :value="item.id"
          >
            {{ item.name }}
          </option>
        </select>
        <img
          :src="`/images/${selectedGeoListItem.marker_type}2_${selectedGeoListItem.color}_x2.png`"
          class="type_icon"
        >
      </div>
      <div>
        <span v-for="item of colors" :key="item.type">
          <input
            :id="item.type"
            v-model="selectedGeoListItem.color"
            type="radio"
            :value="item.type"
            @change="onChange"
          >
          <label
            :for="item.type"
            :style="`color: ${item.color}`"
          >■</label>
        </span>
      </div>
      <label>
        <textarea
          v-model="selectedGeoListItem.memo"
          placeholder="ここにメモする内容を書いてください。"
          @change="onChange"
        />
      </label>
      <button
        class="btn-lg"
        @click="SubmitButtonClick"
      >
        {{ buttonCaption }}
      </button>
      <button
        class="btn-lg"
        :disabled="isButtonDisabled"
        @click="DeleteButtonClick"
      >
        選択されたメモを削除
      </button>
    </div>
  </div>
</template>

import VueGeolocation from 'vue-browser-geolocation';
import moment from 'moment';
import store from 'store2';
import leafletVue from './components/leaflet.vue';
import 'jquery';
import 'vuemerang/dist/vuemerang.css';

〜〜中略〜〜

const cookiehead = '.Horornis-Simple-GPS-Memo-v1_2_2';
const storejs = store;

export default {
  components: {
    leafletVue,
    VueGeolocation,
  },
  data () {
    return {
      colors: [
        {
          type: 'clear',
          color: 'white',
        },
        {
          type: 'black',
          color: 'black',
        },
        {
          type: 'red',
          color: 'red',
        },
        {
          type: 'yellow',
          color: 'yellow',
        },
        {
          type: 'green',
          color: 'lawngreen',
        },
        {
          type: 'blue',
          color: 'blue',
        },
        {
          type: 'purple',
          color: 'magenta',
        },
      ],
      intervalId: undefined,
      latitude: 0,
      longitude: 0,
      memo: '',
      geoList: [],
      statusMode: 'success',
      statusMessage: '新規',
      errorLevel: 'default',
      errorMessage: '誤差: 計測中',
      templateItem: {
        id: null,
        latitude: null,
        longitude: null,
        accuracy: null,
        memo: '',
        time: moment(),
        color: 'yellow',
        marker_type: 'needle',
      },
      color: 'yellow',
      markerType: 'needle',
      markerTypes: [
        {
          id: 'needle',
          name: '針',
        },
        {
          id: 'thumbtack',
          name: '画鋲',
        },
      ],
      selectedId: null,
      buttonCaption: 'タップしてメモを追加',
    };
  },
  computed: {
    isButtonDisabled () {
      return this.selectedId === null;
    },
    selectedGeoListItem () {
      if (this.selectedId) {
        const item = this.geoList.find(v => v.id === this.selectedId);
        if (!item.marker_type) item.marker_type = 'needle';
        // console.log('selectedItem', item)
        return item;
      }
      return this.templateItem;
    },
  },
  mounted () {
    if (storejs.has(cookiehead)) {
      this.geoList = storejs.get(cookiehead);
    }
    this.gpsCheck();
    const _self = this;
    this.intervalId = setInterval(function () {
      _self.gpsCheck();
    }, 60000);
  },
  beforeDestroy () {
    clearInterval(this.intervalId);
    const _self = this;
    this.intervalId = setInterval(function () {
      _self.gpsCheck();
    }, 60000);
  },
  methods: {
    gpsCheck () {
      console.log('Do checking GPS status now');
      this.errorLevel = 'default';
      this.errorMessage = '誤差: 計測中';
      VueGeolocation.getLocation({
        enableHighAccuracy: false, // defaults to false
        timeout: Infinity, // defaults to Infinity
        maximumAge: 0, // defaults to 0
      }).then(coordinates => {
        if (coordinates.accuracy > 50) {
          this.errorLevel = 'danger';
          this.errorMessage = `誤差: ${Math.round(coordinates.accuracy)}m`;
        } else if (coordinates.accuracy > 30) {
          this.errorLevel = 'warning';
          this.errorMessage = `誤差: ${Math.round(coordinates.accuracy)}m`;
        } else if (coordinates.accuracy > 10) {
          this.errorLevel = 'success';
          this.errorMessage = `誤差: ${Math.round(coordinates.accuracy)}m`;
        } else {
          this.errorLevel = 'primary';
          this.errorMessage = `誤差: ${Math.round(coordinates.accuracy)}m`;
        }
      });
    },
    onChange () { // マーカーがタップされたときの処理
      if (this.selectedId) {
        const newGeoList = this.geoList.filter(v => v.id !== this.selectedId);
        newGeoList.push(this.selectedGeoListItem);
        newGeoList.sort((a, b) => {
          if (a.id < b.id) return -1;
          if (a.id > b.id) return 1;
          return 0;
        });
        this.geoList = newGeoList;
        this.buttonCaption = 'タップしてメモを更新';
        this.statusMode = 'warning';
        this.statusMessage = '編集中';
      }
    },
    onMovedItem (event) { // マーカーが移動されたときの処理
      const item = this.geoList.find(v => v.id === event.id);
      if (item) {
        item.latitude = event.latitude;
        item.longitude = event.longitude;
      }
      storejs.set(cookiehead, this.geoList);
    },
    onSelectItem (event) { // マーカーがタップされたときの処理
      this.selectedId = event;
      this.buttonCaption = 'タップしてメモを更新';
      this.statusMode = 'warning';
      this.statusMessage = '編集中';
    },
    SubmitButtonClick () { // マーカーを追加か編集確定
      if (!this.selectedId) {
        VueGeolocation.getLocation({
          enableHighAccuracy: false, // defaults to false
          timeout: Infinity, // defaults to Infinity
          maximumAge: 0, // defaults to 0
        }).then(coordinates => {
          const max = this.geoList.length > 0 ? Math.max(...this.geoList.map(v => v.id)) : 0;
          this.geoList.push({
            id: max + 1,
            latitude: coordinates.lat,
            longitude: coordinates.lng,
            accuracy: coordinates.accuracy,
            memo: this.selectedGeoListItem.memo,
            time: moment(Date.now()),
            color: this.selectedGeoListItem.color,
            marker_type: this.selectedGeoListItem.marker_type,
          });
          if (coordinates.accuracy <= 30) {
            this.$toasted.show(`メモ "${this.selectedGeoListItem.memo}" を追加しました。`, toastOptionsSuccess);
            storejs.set(cookiehead, this.geoList);
          } else {
            this.$toasted.show(
              `メモ "${
                this.selectedGeoListItem.memo
              }" を追加しました。<br />測位誤差が大きいのでマーカーの位置を確認してください。`,
              toastOptionsWarning
            );
            storejs.set(cookiehead, this.geoList);
          }
          this.selectedId = null;
          this.selectedGeoListItem.memo = '';
          this.buttonCaption = 'タップしてメモを追加';
          this.statusMode = 'success';
          this.statusMessage = '新規';
        });
      } else {
        this.selectedId = null;
        this.selectedGeoListItem.memo = '';
        this.buttonCaption = 'タップしてメモを追加';
        this.statusMode = 'default';
        this.statusMessage = '新規';
      }
    },
    DeleteButtonClick () {
      const aItem = this.selectedGeoListItem;
      this.selectedId = null;
      this.$toasted.show(`"${aItem.memo}"を削除しました。`, toastOptionsSuccess);
      this.geoList = this.geoList.filter(v => v.id !== aItem.id);
      storejs.set(cookiehead, this.geoList);
      this.selectedId = null;
      this.buttonCaption = 'タップしてメモを追加';
      this.statusMode = 'default';
      this.statusMessage = '新規';
    },
  },
〜〜中略〜〜

```
# 総括
　そんなにトリッキーなことはやってないつもりですが、無駄は多いかもしれません。マーカーオブジェクトはそれそのものを保存しようか考えましたが、それは逆に扱いづらそうなので、Ｏｂｊｅｃｔ配列をそのまま利用してます。

　まだ、万人向けには直すべきところは多いと思いますが、とりあえず自分でちゃんと使える形にはなったので、要望とかがあれば、機能追加しようかな？と思います。

　最後まで読んでくださり、ありがとうございました。