【JavaScript】Yahoo! JavaScriptマップAPIで地上絵を書くアプリを作る【後編】(完成コードとこだわりについて)

プログラミング初心者の勉強ブログ #122

完成品を披露する記事。JSのClass記法を織り交ぜつつ、メソッドをイベントハンドラで上手いこと使いまわせるよう設計を意識した。一時的に保存したい情報は全てLocal Storageを利用したので、iOSやAndroidで思わぬ挙動などないか少し不安であったが、正常に機能した。PWAを実装し、一枚のHTMLとJSのみでアプリ感をある程度出せた気がする。

 

目次

[toc]

 

GeoPict

geopict

http://geopict.supunic.com

「地上絵を描いてシェアしよう」的なアプリ。自分の現在位置情報を記録していき、マップ上に絵を描いていくというアソビ。SNSに投稿してもらえたらなという考えのもと、作成した絵はマップごと画像化できる。今まであまり使ってこなかったMAP APIやPWAの練習作品であり、少しづつではあるが、JavaScriptでイメージした物を作れるようになってきた気がする。とは言っても、非同期処理の関連(promiseカンケー)はまだまだ自分で思うように書くことができなかったので、今後の課題として意識を持って置きたい。

 

自分で使ってみた

map

渋谷駅をぐるっと囲むようにミッキーを描いた。散々道に迷い2時間かかった。こんな疲れるアプリは誰も使わないだろうと感じた。UXとはこういうことか、開発段階では確かに考えていなかった。

 

実際のコード

コードを載せて参考になるかはわからないが、とりあえず載せる。今回の実装ではJSで独自クラスを2つ設けてみた。GPクラス(gp.js)とMapクラス(map.js)、そしてmain.js。

 

gp.js

class GP {
  constructor() {
    // btn
    this.startBtn = $('#startBtn');
    this.logBtn = $('#logBtn');
    this.undoBtn = $('#undoBtn');
    this.resetBtn = $('#resetBtn');
    this.endBtn = $('#endBtn');
    this.backBtn = $('#backBtn');
    this.reloadBtn = $('#reloadBtn');
    this.zoomInBtn = $('#zoomInBtn');
    this.zoomOutBtn = $('#zoomOutBtn');
    this.lineBoldBtn = $('#lineBoldBtn');
    this.lineSharpBtn = $('#lineSharpBtn');

    // page
    this.hero = $('.hero');
    this.draw = $('.draw');
    this.complete = $('.complete');

    // others
    this.insert = $('#insert');
    this.geopict = $('#geopict');
  }
}

GPクラスは、index.htmlにあらかじめ書き込んでいる要素を全て閉じ込めている。このクラスでインスタンスを作り、それをメインのjsファイルで使いたいときに呼び出す設計にしてみた。取得する要素がリスト化でき管理がしやすいであろうという点でこういう形を試してみた。

 

次はMapクラスについて。今回はYahooMapAPIを使っている。前編参照。

【JavaScript】Yahoo! JavaScriptマップAPIで地上絵を書くアプリを作る【前編】(静止画変換や他のMap APIとの比較など)

 

map.js

class Map {
  // 初期化
  constructor(lat, lon, zoom, polyline) {
    this.lat = lat;
    this.lon = lon;
    this.icon = new Y.Icon('./img/pin.png');
    this.marker = new Y.Marker(new Y.LatLng(this.lat, this.lon), {icon: this.icon});
    this.zoom = zoom ? zoom : 16;
    this.polyline = polyline ? polyline : 5;
    this.body = new Y.Map("map", {
      configure : {
        mapType : Y.Map.TYPE.SMARTPHONE,
        continuousZoom : true,
        enableFlickScroll : true
      }
    });
  }

  // map表示関係
  init() {
    this.body.drawMap(new Y.LatLng(this.lat, this.lon), this.zoom, Y.LayerSetId.NORMAL);
    this.body.addFeature(this.marker);
  }

  // 静止画生成時の中心座標取得メソッド
  getCenter(pArray) {
    let latArray = [];
    let lonArray = [];
    pArray.forEach((p, index) => {
      const i = index + 1;
      if (p[i]) {
        latArray.push(p[i].lat);
        lonArray.push(p[i].lon);
      };
    });
    const cLat = (Math.max.apply(null, latArray) + Math.min.apply(null, latArray)) / 2;
    const cLon = (Math.max.apply(null, lonArray) + Math.min.apply(null, lonArray)) / 2;
    return [cLat, cLon];
  }

  // 線の描画メソッド
  drawPolyline(array) {
    const style = new Y.Style("ff004b", this.polyline, 0.8);
    const latlngs = [];
    array.forEach((e, index) => {
      const i = index + 1;
      if (e[i]) latlngs.push(new Y.LatLng(e[i].lat, e[i].lon));
    });
    const polyline = new Y.Polyline(latlngs, {strokeStyle: style});
    this.body.addFeature(polyline);
  }

  // 静止画化メソッド
  setStaticMap(cLat, cLon, pArray) {
    const url = "https://map.yahooapis.jp/map/V1/static";
    const id = "yahoo_MAP_API_ID";
    const w = "500";
    const h = "500";
    const l = this.setStaticMapPolyline(pArray)
    const zoom = String(this.zoom);
    const format = `${url}?appid=${id}&l=${l}&lat=${cLat}&lon=${cLon}&z=${zoom}&width=${w}&height=${h}`;
    return `<img src="${format}" class="resultImg">`;
  }
  
  // 静止画に描画する線の情報をAPIに送るためのクエリパラメータ設定メソッド
  setStaticMapPolyline(pArray) {
    const r = "255";
    const g = "0";
    const b = "75";
    const opacity = "0.8";
    const lineWidth = String(this.polyline);
    let position = "";
    pArray.forEach((p, index) => {
      const i = index + 1;
      if (p[i]) position += `,${p[i].lat},${p[i].lon}`;
    });
    return `${r},${g},${b},${opacity},${lineWidth}${position}`;
  }
}

色々こだわって書いている部分があるので紹介していく。

 

初期化関係

constructor(lat, lon, zoom, polyline) {
    this.lat = lat;
    this.lon = lon;
    this.icon = new Y.Icon('./img/pin.png');
    this.marker = new Y.Marker(new Y.LatLng(this.lat, this.lon), {icon: this.icon});
    this.zoom = zoom ? zoom : 16;
    this.polyline = polyline ? polyline : 5;
    this.body = new Y.Map("map", {
      configure : {
        mapType : Y.Map.TYPE.SMARTPHONE,
        continuousZoom : true,
        enableFlickScroll : true
      }
    });
  }

init() {
  this.body.drawMap(new Y.LatLng(this.lat, this.lon), this.zoom, Y.LayerSetId.NORMAL);
  this.body.addFeature(this.marker);
}

 

constructorとinitで基本的な設定を行っている。ここでのこだわりポイントは

this.zoom = zoom ? zoom : 16;
this.polyline = polyline ? polyline : 5;

ここである。静止画化する場合、線の太さとズームの度合いをユーザーが選択できるようにしなければならない訳で、なぜならユーザーによって描くであろう地上絵のスケールが違う。距離的に小さい地上絵と大きい地上絵で、静止画かするために使用するAPIに送らなければならないズーム度合いと線の太さを変える必要がある。

 

geopict操作画面

上の画像はアプリの操作画面だが、「zoom」ボタンと「line」ボタンが押されるたびに、Mapクラスからインスタンスを毎回生成し、生成されるインスタンス毎にthisでzoom情報を格納する。完成ボタンのクリック時に、そのとき生成されているインスタンスのzoom情報をAPIに送信する。

 

zoom情報は、1〜20までの数値で判断される(1がもっとも離れており、20が最大ズーム状態)。そのため、zoomのプラスボタンが押されるタイミングでzoom情報をプラス1していく仕組みが必要になる。つまり、一旦zoom情報をどこかしらに保存しておかなければならない。そこでLocal Storageを使用する。

 

localstoreage_before

最初のmapインスタンス初期化のタイミングで、あらかじめlocal storage内に「zoomLebel」と「pWidth」を保存しておく。pWidthは線の太さ(px指定)であり、初期値のzoom情報は16、線の太さは5pxにしている。

 

次に、zoomのプラスボタンが押されたとき、

localstorage_after

zoomLevelが17に変化するようにしている。

イメージとしては、

 

画面が開く

→mapインスタンス生成

→mapインスタンスのzoom値をlocal storageに保存

→zoom+ボタンが押される

→local storageにzoom値に+1した値でmapインスタンスを再生成

→mapインスタンスのzoom値をlocal storageに保存

・・・以下繰り返し。

 

 

みたいな感じにしてある。(lineについても同様。)

写真には載っていないが、ユーザーが記録させた位置情報(経度と緯度)もlocal storageに保存させている。

 

静止画化関係

とりあえずAPIに送るパラメータ部分について、変数に置き換えて整理した。

setStaticMap(cLat, cLon, pArray) {
    const url = "https://map.yahooapis.jp/map/V1/static";
    const id = "yahoo_MAP_API_ID";
    const w = "500";
    const h = "500";
    const l = this.setStaticMapPolyline(pArray)
    const zoom = String(this.zoom); // mapインスタンスのzoom情報を使う
    const format = `${url}?appid=${id}&l=${l}&lat=${cLat}&lon=${cLon}&z=${zoom}&width=${w}&height=${h}`;
    return `<img src="${format}" class="resultImg">`;
}
  
setStaticMapPolyline(pArray) {
    const r = "255";
    const g = "0";
    const b = "75";
    const opacity = "0.8";
    const lineWidth = String(this.polyline); // mapインスタンスのline情報を使う
    let position = "";
    pArray.forEach((p, index) => {
      const i = index + 1;
      if (p[i]) position += `,${p[i].lat},${p[i].lon}`;
    });
    return `${r},${g},${b},${opacity},${lineWidth}${position}`;
}

 

あとは静止画化するためには画像にするための中心座標を求める必要がある。そうしないと、書いた絵が中心にこない。色々考えたが、ユーザーが記録させた各座標の経度と緯度をソートさせ、最小値と最大値を足して2で割ることにした。今回の場合、それでいい感じになる。

 

// 静止画生成時の中心座標取得メソッド
  getCenter(pArray) {
    let latArray = [];
    let lonArray = [];
    pArray.forEach((p, index) => {
      const i = index + 1;
      if (p[i]) {
        latArray.push(p[i].lat);
        lonArray.push(p[i].lon);
      };
    });
    const cLat = (Math.max.apply(null, latArray) + Math.min.apply(null, latArray)) / 2;
    const cLon = (Math.max.apply(null, lonArray) + Math.min.apply(null, lonArray)) / 2;
    return [cLat, cLon];
  }

 

ここでその処理を行なっている。リターンさせている[cLat, cLon]が中心の経度と緯度になる。

 

main.js

const initGeolocation = () => {
  // 位置情報取得成功時の処理
  const mapsInit = position => {
    let lat = position.coords.latitude;
    let lon = position.coords.longitude;
    if (!localStorage.getItem('zoomLebel')) localStorage.setItem('zoomLebel', 16);
    if (!localStorage.getItem('pWidth')) localStorage.setItem('pWidth', 5);    
    const zoom = localStorage.getItem('zoomLebel');
    const polyline = localStorage.getItem('pWidth');
    const map = new Map(lat, lon, zoom, polyline);
    
    if (localStorage.getItem('finished') === "true") {
      const pArray = createNodeArray();
      const [cLat, cLon] = map.getCenter(pArray);
      const mapImg = map.setStaticMap(cLat, cLon, pArray);
      g.geopict.html(mapImg);
      localStorage.removeItem('finished');
      return;
    }
    map.init();
    if (localStorage.getItem('clickLogBtn') === "true" && nodeCount !== 0) {
      setLocalStorage(lat, lon);
      localStorage.removeItem('clickLogBtn');
    }
    map.drawPolyline(createNodeArray());
  };

  // 位置情報取得エラー時の処理
  const mapsError = error => {
    let e = "";
    if (error.code == 1) e = "位置情報が許可されてません";
    if (error.code == 2) e = "現在位置を特定できません";
    if (error.code == 3) e = "位置情報を取得する前にタイムアウトになりました";
    alert("エラー:" + e);
  };

  // 位置情報取得の設定処理
  const mapsConf = {
    enableHighAccuracy: true,
    maximumAge: 20000,
    timeout: 15000
  };

  // storage内の位置情報の数カウント処理
  const countLocalStorageNode = () => {
    let n = 0;
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (key.indexOf('node_') !== -1) n++;
    }
    return n;
  }

  // storageに保存されている位置情報のJSON形式変更処理
  const createNodeArray = () => {
    let array = [];
    for (let i = 1; i <= nodeCount; i++) {
      const key = i;
      const value = JSON.parse(localStorage.getItem(`node_${i}`));
      array.push({[key]: value});
    }
    return array;
  }

  // storage保存処理
  const setLocalStorage = (lat, lon) => {
    const key = `node_${nodeCount}`;
    const value = JSON.stringify({
      lat: lat,
      lon: lon
    });
    localStorage.setItem(key, value);
  }

  // map表示処理
  const displayMap = () => {
    navigator.geolocation.getCurrentPosition(mapsInit, mapsError, mapsConf);
    g.hero.css('display', 'none');
    g.draw.css('display', 'block');
    g.insert.html('<div id="map" style="width:100%;"><img src="./img/preloader.gif" class="loader" alt="loader"></div>');

    const ua = navigator.userAgent;
    if (ua.indexOf('iPhone') > 0 || ua.indexOf('Android') > 0 && ua.indexOf('Mobile') > 0) {
      $('#map').css('height', `${screen.width}`);  
    } else if (ua.indexOf('iPad') > 0 || ua.indexOf('Android') > 0) {
      $('#map').css('height', '500px');
    } else {
      $('#map').css('height', '500px');
    }
  };
    
  // インスタンス生成やクリックイベント設定
  const g = new GP;
  localStorage.setItem('clickLogBtn', false);
  let nodeCount = countLocalStorageNode();
  if (nodeCount !== 0) {
    displayMap();
  } else {
    g.startBtn.css('display', 'block');
  }
  
  g.startBtn.on('click', () => {
    displayMap();
  });

  g.logBtn.on('click', () => {
    nodeCount++;
    localStorage.setItem('clickLogBtn', true);
    displayMap();
  });

  g.undoBtn.on('click', () => {
    if (nodeCount > 0) {
      if (confirm('1つ前の位置情報を削除します。よろしいですか?')) {
        localStorage.removeItem(`node_${nodeCount}`);
        nodeCount = countLocalStorageNode();
        displayMap();
      }
    }
  });

  g.resetBtn.on('click', () => {
    if (nodeCount > 0) {
      if (confirm('全てのの位置情報を削除します。よろしいですか?')) {
        localStorage.clear();
        nodeCount = countLocalStorageNode();
        displayMap();
      }
    }
  });

  g.endBtn.on('click', () => {
    if (nodeCount > 0) {
      if (confirm('完了でよろしいですか?')) {
        localStorage.setItem('finished', true);
        g.complete.css('display', 'block');
        g.draw.css('display', 'none');
        navigator.geolocation.getCurrentPosition(mapsInit, mapsError, mapsConf);
      }
    }
  });

  g.backBtn.on('click', () => {
    if (nodeCount > 0) {
      if (confirm('作成画面に戻ります。よろしいですか?')) {
        g.complete.css('display', 'none');
        g.geopict.html("");
        displayMap();
      }
    }
  });

  g.reloadBtn.on('click', () => {
    displayMap();
  });

  g.zoomInBtn.on('click', () => {
    let z = Number(localStorage.getItem('zoomLebel'));
    if (z === 20) return;
    z++;
    localStorage.setItem('zoomLebel', z);
    displayMap();
  });

  g.zoomOutBtn.on('click', () => {
    let z = Number(localStorage.getItem('zoomLebel'));
    if (z === 1) return;
    z--;
    localStorage.setItem('zoomLebel', z);
    displayMap();
  });

  g.lineBoldBtn.on('click', () => {
    let p = Number(localStorage.getItem('pWidth'));
    if (p === 20) return;
    p++;
    localStorage.setItem('pWidth', p);
    displayMap();
  });

  g.lineSharpBtn.on('click', () => {
    let p = Number(localStorage.getItem('pWidth'));
    if (p === 1) return;
    p--;
    localStorage.setItem('pWidth', p);
    displayMap();
  });
};

 

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>地上絵を書いてシェアしよう - GeoPict</title>
  <meta name="format-detection" content="telephone=no">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <meta name="description" content="地上絵を書いてシェアしよう - GeoPictは、位置情報で地上絵を簡単にかけるサービスです。自分の位置情報を記録し、地図上に線を引き好きな絵や図形を書くことができます。">
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:site" content="geopict1">
  <meta property="og:site_name" content="地上絵を書いてシェアしよう - GeoPict">
  <meta property="og:type" content="website">
  <meta property="og:url" content="https://geopict.supunic.com/img/ogp_icon.png">
  <meta property="og:title" content="地上絵を書いてシェアしよう - GeoPict">
  <meta property="og:description" content="地上絵を書いてシェアしよう - GeoPictは、位置情報で地上絵を簡単にかけるサービスです。自分の位置情報を記録し、地図上に線を引き好きな絵や図形を書くことができます。">
  <meta property="og:image" content="https://geopict.supunic.com/img/ogp_icon.png">
  <link rel="apple-touch-icon" sizes="180x180" href="./img/touch_icon.png">
  <link rel="icon" type="image/png" href="/img/touch_icon.png" sizes="32x32">
  <link rel="icon" type="image/png" href="/img/touch_icon.png" sizes="16x16">
  <!-- PWA -->
  <meta name="theme-color" content="#F8F7F8">
  <link rel="manifest" href="./manifest.json">
  <script>
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./sw.js');
  }
  </script>
  <!-- /PWA -->
  <link href="https://fonts.googleapis.com/css?family=Muli|Noto+Sans+JP&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.2/css/all.css" integrity="sha384-oS3vJWv+0UjzBfQzYUhtDYW+Pj2yciDJxpsK1OYPAYjqT085Qq/1cq5FLXAZQ7Ay" crossorigin="anonymous">
  <link rel="stylesheet" href="./css/reset.css?201905202300">
  <link rel="stylesheet" href="./css/main.css?201905202300">
</head>
<body>
  <div class="container">

    <div class="hero">
      <h1 class="hero-ttl ttl">
        GeoPict
        <span>地上絵を書いてシェアしよう</span>
      </h1>
      <img class="hero-img" src="./img/heroImg.png" alt="heroImg">
      <div class="hero-btn btn" id="startBtn" style="display: none;">START</div>
    </div>

    <div class="draw" style="display: none;">
      <header class="draw-header">
        <div class="draw-header-ttl ttl">GeoPict</div>
        <div class="draw-header-btn btn" id="endBtn">完成</div>
        <div class="sns">
          <a href="https://www.instagram.com/geopict_exhibition" class="insta"><i class="fab fa-instagram"></i></a>
          <a href="https://twitter.com/geopict1" class="twitter"><i class="fab fa-twitter"></i></a>
        </div>
      </header>
      <div class="draw-insert" id="insert"></div>
      <div class="draw-adjust">
        <div class="draw-adjust-reload" id="reloadBtn"><i class="fas fa-location-arrow"></i></div>
        <div class="wrap">
          <div class="idiv" id="lineSharpBtn"><i class="far fa-minus-square"></i></div>
          <span class="configTxt">line</span>
          <div class="idiv" id="lineBoldBtn"><i class="far fa-plus-square"></i></div>
        </div>
        <div class="wrap">
          <div class="idiv" id="zoomOutBtn"><i class="far fa-minus-square"></i></div>
          <span class="configTxt">zoom</span>
          <div class="idiv" id="zoomInBtn"><i class="far fa-plus-square"></i></div>
        </div>
      </div>
      <div class="wrap">
        <div class="draw-btn btn" id="resetBtn">リセット</div>
        <div class="draw-btn btn" id="undoBtn">1つ前に戻す</div>
        <div class="draw-btn btn" id="logBtn">位置を記録</div>
      </div>
    </div>

    <div class="complete" style="display: none;">
      <header class="complete-header">
        <div class="complete-header-ttl ttl">GeoPict</div>
        <div class="complete-header-btn btn" id="backBtn">戻る</div>
        <div class="sns">
          <a href="https://www.instagram.com/geopict_exhibition" class="insta"><i class="fab fa-instagram"></i></a>
          <a href="https://twitter.com/geopict1" class="twitter"><i class="fab fa-twitter"></i></a>
        </div>
      </header>
      <img src="./img/preloader.gif" class="complete-loader" alt="loader">
      <div class="complete-insert" id="geopict"></div>
      <p class="complete-txt" id="saveBtn">長押しで保存できます</p>
      <a class="complete" style="display: none;"></a>
    </div>
  </div>
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
  <script type="text/javascript" charset="utf-8" src="https://map.yahooapis.jp/js/V1/jsapi?appid=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"></script>
  <script src="./js/map.js?201905202300"></script>
  <script src="./js/gp.js?20190520202300"></script>
  <script src="./js/main.js?201905202300"></script>
  <script>
    (() => {
      'use strict';
      initGeolocation();
    })();
  </script>
</body>
</html>

 

 

まとめ

コードたくさん載せたらめちゃめちゃ長くなった。PWAの実装については別の記事として書こうと思っている。

以上ありがとうございました。

返信を残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

CAPTCHA