Asial Blog

Recruit! Asialで一緒に働きませんか?

AngularJSとOnsen UI で作るGoogle Mapsのサンプルアプリ

カテゴリ :
フロントエンド(HTML5)
タグ :
Monaca
Onsen UI
HTML5
angularjs
今回の記事は、Onsen UI blogで2月に公開した"Creating Google Maps Sample App with AngularJS and Onsen UI"の翻訳記事です。



以下のツールを使用して、このサンプルを構築します。

Onsen UI ( HTML5 を使用したハイブリッドアプリを作成するためのフレームワーク )
AngularJS ( Google 社が開発した JavaScript のフレームワーク )
Google Maps JavaScript API v3

ここでは、Monacaを使用して、アプリを開発します。Monaca は、クロウド型のアプリ開発環境です。このツールを使用すれば、マルチプラットフォーム ( iOS/Android/Windows8 ) に対応するハイブリッドアプリを開発できます。Monaca には、Onsen UI のフルサポート、テンプレートの提供など、開発を容易にしてくれる作り込みが多数してあります。これらの点が、今回、Monaca を使用して、開発を行う理由です。

このサンプルアプリに実装している機能は、上記ツールの解説に必要となる、最低限に抑えてあります。また、ここでは、複雑・大規模アプリの構築ではなく、拡張可能な、ひな形となるアプリの構築に焦点を当てています。よって、アプリ内の機能のいくつかは、表示されているだけで、機能しません。開発者側で自由に書き換えれるように、枠組みだけ、提供しています。



実装している機能を、以下に記します。


  • マーカーの表示 ( 画面上で長押し )

  • マーカーの削除 ( 1 件、画面上でタップ )

  • マーカーの削除 ( 全件、<ons-button> を使用)

  • マーカー間の距離 ( 2 点間の距離、総距離 ) の計算 ( <ons-button> を使用 )

  • メニューの表示 ( <ons-sliding-menu> を使用 )



<ons-sliding-menu> を使用して、メインのインターフェイス ( ページ選択の画面 ) を配置し、その上に、map.html と settings.html へのリンクを置いています。map.html には、map 関連の要素と Onsen UI の要素 ( <ons-button> など ) を記述しています。スライティングメニュー上から、map 画面または settings 画面へ遷移できます。

上のサンプルアプリは、実際に操作できます。または、こちらのリンクからブラウザー表示も行えます。GitHub上にも、コードを置いていますので、開発者の環境で検証もできます。

また、上述の HTML コンテンツの他にも、controller.js と style.css の 2 つの重要な部品から、このアプリは構成されています。

コントローラー

コントローラーには、アプリのロジックを記述しています。このアプリには、2 つのコントローラーを使用しています。1 つは、ons-sliding-menu の制御用で、もう 1 つは、地図の制御用です。どちらのコントローラーも、controller.js 内に記述されています。

SlidingMenuController

ons-sliding-menu 要素と Google Maps 関連の要素を、同時に使用する際の問題点として、スクロール処理のイベントに、どちらも影響されることが挙げられます ( ここでは、水平方向のスワイプ )。よって、スライディングメニューのスワイプ時の挙動を、コントローラー側で制御する必要があります。
ここでは、この問題を解消するために、 'postopen' または 'postclose' イベントの制御を行う要素に、リスナーを登録します。スライディングメニューのスワイプは、デフォルトでは、無効化されていますが、ons-toolbar-button をクリックして、メニューを開くと有効化され、メニューが閉じるまで ( スワイプまたは ons-toolbar-button の再実行 )、そのまま有効化されています。このリスナーは、スライディングメニューを初回に開いた後に、設定されます。

MapController

このコントローラーには、map オブジェクト関連のロジックと Onsen UI 関連のロジック ( Google Maps と Onsen UI との連携部分 ) を記述しています。最初の処理は、maker 用の配列の作成 ( 後から使用 ) と map 要素の作成です。ここでは、地図
の初期化時に、AngularJS の $timeout を使用して、地図の読み込みを、100ms ほど、遅延させます。DOM を読み込む前に、AngularJS のコントローラーの初期化が行われるため、この処理が必要となります。

地図の読み込み後、タップイベント ( マーカーの表示 ) に対して、リスナーを設定します。ここでは、Hammer.JS ライブラリーを使用します。Hammer.JS は、Onsen UI に、初めから組み込まれています。

地図の初期化後、上述した機能が使用できます。上述の機能には、それぞれ、対応するメソッドがあります ( $scope.addOnClick()、$scope.deleteAllMarkers()、$scope.calculateDistance() )。

$scope.addOnClick() は、上述したリスナーと紐付けされています。画面のクリック/タップ位置 ( X値、Y値 ) を取得して、経緯・緯度の座標に変換します。Google Maps では、このような形式でエンコード化が行われているため、この変換処理が必要となります。この処理には、以下のような関数を使用します。

  1. $scope.overlay.getProjection().fromContainerPixelToLatLng(point);

地図を入れる div の左上の地点に、座標の原点があります ( 端末の画面の左上ではありません )。これが、Y 座標の取得時に、(-) 44px 分だけ、差し引く理由です ( 44px は、画面上部に置かれた <ons-toolbar> 要素の高さです )。座標の変換後、地図上に、マーカーを表示できます ( マーカー用の配列を使用 )。

次に、マーカーに対するクリック/タップに備えて、リスナーを設定します。マーカーが押された場合、ons.notification.confirm 要素を使用して、マーカーを削除するか否か、ユーザー側に確認します。次に、ユーザーの選択肢に応じて、ons.notification.alert 要素を表示します。

  1. // controller.js
  2.  
  3. (function() {
  4.     var app = angular.module('myApp', ['onsen']);
  5.  
  6.     //Sliding menu controller, swiping management
  7.     app.controller('SlidingMenuController', function($scope){
  8.  
  9.         $scope.checkSlidingMenuStatus = function(){
  10.  
  11.             $scope.slidingMenu.on('postclose', function(){
  12.                 $scope.slidingMenu.setSwipeable(false);
  13.             });
  14.             $scope.slidingMenu.on('postopen', function(){
  15.                 $scope.slidingMenu.setSwipeable(true);
  16.             });
  17.         };
  18.  
  19.         $scope.checkSlidingMenuStatus();
  20.     });
  21.  
  22.     //Map controller
  23.     app.controller('MapController', function($scope, $timeout){
  24.  
  25.         $scope.map;
  26.         $scope.markers = [];
  27.         $scope.markerId = 1;
  28.  
  29.         //Map initialization  
  30.         $timeout(function(){
  31.  
  32.             var latlng = new google.maps.LatLng(35.7042995, 139.7597564);
  33.             var myOptions = {
  34.                 zoom: 8,
  35.                 center: latlng,
  36.                 mapTypeId: google.maps.MapTypeId.ROADMAP
  37.             };
  38.             $scope.map = new google.maps.Map(document.getElementById("map_canvas"), myOptions); 
  39.             $scope.overlay = new google.maps.OverlayView();
  40.             $scope.overlay.draw = function() {}; // empty function required
  41.             $scope.overlay.setMap($scope.map);
  42.             $scope.element = document.getElementById('map_canvas');
  43.             $scope.hammertime = Hammer($scope.element).on("hold", function(event) {
  44.                 $scope.addOnClick(event);
  45.             });
  46.  
  47.         },100);
  48.  
  49.         //Delete all Markers
  50.         $scope.deleteAllMarkers = function(){
  51.  
  52.             if($scope.markers.length == 0){
  53.                 ons.notification.alert({
  54.                     message: 'There are no markers to delete!!!'
  55.                 });
  56.                 return;
  57.             }
  58.  
  59.             for (var i = 0; i < $scope.markers.length; i++) {
  60.  
  61.                 //Remove the marker from Map                  
  62.                 $scope.markers[i].setMap(null);
  63.             }
  64.  
  65.             //Remove the marker from array.
  66.             $scope.markers.length = 0;
  67.             $scope.markerId = 0;
  68.  
  69.             ons.notification.alert({
  70.                 message: 'All Markers deleted.'
  71.             });   
  72.         };
  73.  
  74.         $scope.rad = function(x) {
  75.             return x * Math.PI / 180;
  76.         };
  77.  
  78.         //Calculate the distance between the Markers
  79.         $scope.calculateDistance = function(){
  80.  
  81.             if($scope.markers.length < 2){
  82.                 ons.notification.alert({
  83.                     message: 'Insert at least 2 markers!!!'
  84.                 });
  85.             }
  86.             else{
  87.                 var totalDistance = 0;
  88.                 var partialDistance = [];
  89.                 partialDistance.length = $scope.markers.length - 1;
  90.  
  91.                 for(var i = 0; i < partialDistance.length; i++){
  92.                     var p1 = $scope.markers[i];
  93.                     var p2 = $scope.markers[i+1];
  94.  
  95.                     var R = 6378137; // Earth’s mean radius in meter
  96.                     var dLat = $scope.rad(p2.position.lat() - p1.position.lat());
  97.                     var dLong = $scope.rad(p2.position.lng() - p1.position.lng());
  98.                     var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
  99.                     Math.cos($scope.rad(p1.position.lat())) * Math.cos($scope.rad(p2.position.lat())) *
  100.                     Math.sin(dLong / 2) * Math.sin(dLong / 2);
  101.                     var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  102.                     totalDistance += R * c / 1000; //distance in Km
  103.                     partialDistance[i] = R * c / 1000;
  104.                 }
  105.  
  106.  
  107.                 ons.notification.confirm({
  108.                     message: 'Do you want to see the partial distances?',
  109.                     callback: function(idx) {
  110.  
  111.                         ons.notification.alert({
  112.                             message: "The total distance is " + totalDistance.toFixed(1) + " km"
  113.                         });
  114.  
  115.                         switch(idx) {
  116.                             case 0:
  117.  
  118.                                 break;
  119.                             case 1:
  120.                                 for (var i = (partialDistance.length - 1); i >= 0 ; i--) {
  121.  
  122.                                     ons.notification.alert({
  123.                                         message: "The partial distance from point " + (i+1) + " to point " + (i+2) + " is " + partialDistance[i].toFixed(1) + " km"
  124.                                     });
  125.                                 }
  126.                                 break;
  127.                         }
  128.                     }
  129.                 });
  130.             }
  131.         };
  132.  
  133.         //Add single Marker
  134.         $scope.addOnClick = function(event) {
  135.             var x = event.gesture.center.pageX;
  136.             var y = event.gesture.center.pageY-44;
  137.             var point = new google.maps.Point(x, y);
  138.             console.log(x + " - " + y);
  139.             var coordinates = $scope.overlay.getProjection().fromContainerPixelToLatLng(point);
  140.  
  141.             console.log(coordinates.lat + ", " + coordinates.lng);
  142.  
  143.             var marker = new google.maps.Marker({
  144.                 position: coordinates,
  145.                 map: $scope.map
  146.             });
  147.  
  148.             marker.id = $scope.markerId;
  149.             $scope.markerId++;
  150.             $scope.markers.push(marker);
  151.  
  152.             $timeout(function(){
  153.                 //Creation of the listener associated to the Markers click
  154.             google.maps.event.addListener(marker, "click", function (e) {
  155.                 ons.notification.confirm({
  156.                     message: 'Do you want to delete the marker?',
  157.                     callback: function(idx) {
  158.                         switch(idx) {
  159.                             case 0:
  160.                                 ons.notification.alert({
  161.                                     message: 'You pressed "Cancel".'
  162.                                 });
  163.                                 break;
  164.                             case 1:
  165.                                 for (var i = 0; i < $scope.markers.length; i++) {
  166.                                     if ($scope.markers[i].id == marker.id) {
  167.                                         //Remove the marker from Map                  
  168.                                         $scope.markers[i].setMap(null);
  169.  
  170.                                         //Remove the marker from array.
  171.                                         $scope.markers.splice(i, 1);
  172.                                     }
  173.                                 }
  174.                                 ons.notification.alert({
  175.                                     message: 'Marker deleted.'
  176.                                 });
  177.                                 break;
  178.                         }
  179.                     }
  180.                 });
  181.             });
  182.             },1000);
  183.  
  184.  
  185.         };
  186.     });
  187. })();

$scope.deleteAllMarkers() で行う処理は、シンプルです。マーカーの配列を確認して、地図上から、すべてのマーカーを削除します。次に、配列から、すべてのデータを削除して、配列のインデックスを初期化します。

$scope.calculateDistance() では、地図上のマーカー間の距離を計算します。ここでは、Web 上で見つけた計算式を使用しています。この計算処理の結果と座標は、同じ形式を使用しているため、変換処理は必要ありません。
計算後、ons.notification.confirm 要素を使用して、特定のマーカー間の距離 ( Partial Distance ) を表示するか否か、メッセージを表示します。ユーザーの選択肢に応じて、ここでも、ons.notification.alert 要素を使用します。

CSS

Onsen UI がデフォルトで提供するスタイルシート ( CSS ) とは別に、DOM 要素用のスタイルを、新たに追加します。CSS の内容は、style.css をご確認ください。ここでは、Google Maps JavaScript API v3 関連の要素と Onsen UI 関連の要素の表示処理を、適当に行うため、スタイルを新たに追加します。Google Maps JavaScript API v3 と Onsen UI の提供元は異なるため、これらの要素を完全に連携させることはできません。よって、これらの要素を表示するため、2 層のレイヤーを使用します。

下位のレイヤーには、Google Maps の要素 ( 地図、マーカー ) を置き、上位のレイヤーには、Onsen UI の要素を置くことにします。
ここでは、スタイルシートに追加した z-index を使用して、各要素を振り分けます。Maps 関連の要素には、-1 を設定して、下位のレイヤーにプッシュし、Onsen UI 関連の要素には、1 を設定して、上位のレイヤーにプッシュします。

Onsen UI 側の要素の位置を、適切に指定して、Google Maps 側の要素との重複表示を防ぎます。また、要素を適切に配置するためには、画面の解像度も考慮に入れます。要素自体のサイズも重要です。たとえば、div が大きすぎでも、使いにくいマップとなります。

  1. #map_canvas {
  2.     position: absolute; 
  3.     width:100%; 
  4.     height: 100%; 
  5.     margin: 0; 
  6.     padding: 0;   
  7.     z-index: -1
  8. }
  9.  
  10. .search-input-map{
  11.     width:60%;
  12.     z-index:1;
  13.     margin-left: auto;
  14.     margin-right:auto;
  15. }
  16.  
  17. .par-search{
  18.     margin-top:10%
  19. }
  20.  
  21. .par-buttons{
  22.     position: absolute; 
  23.     text-align: center; 
  24.     width:100%; 
  25.     bottom:0px
  26. }
  27.  
  28. .btn-delete{
  29.     z-index:1
  30. }
  31.  
  32. .btn-distance{
  33.     z-index:1; margin-left: 5px;

HTML

index.html、menu.html、map.html、settings.html の 4 つのファイルから、このアプリは構成されています ( settings.html の解説は割愛 )。これらのページには、HTML、Onsen UI、AngularJS の要素が記述されています。アプリの大枠には、Onsen UI 提供のスライディングメニューのテンプレートを使用しています。こちらのテンプレートは、Monacaから入手できます。どのテンプレートを使用するかは、開発者側の自由です。DOM の取り扱い方法などに、他のアイデアがある方は、他のテンプレートをご使用ください。

index.html

こちらが、アプリのメインページとなります ( Onsen UI のテンプレートの使用時には、デフォルトで作成されます )。HTML の宣言タグの後に、ng-app="myApp" が記述されています。このディレクティブ ( directive ) を使用して、AngularJS アプリの、いわゆる、自動初期化 ( bootstrap ) を行います。また、このディレクティブは、その役割から、アプリの root 要素を指し示し、通常、ページの root 要素 ( <body> タグ、<html> タグなど ) 付近に置かれます。

スクリプトとシートを記述後、地図を表示するため、Google Maps のスクリプトを記述します。前のバージョンの Google Maps JavaScript API 以降、キーの取得・使用は、任意となりましたが、 Maps API の使用量の監視、追加の割り当ての購入の際には、必要となります。無料の Google Maps API キーの取得・記述方法に関しては、こちらのリンクをご確認ください。

以下のタグを使用して、地図を表示します。

  1. <script type="text/javascript" src="https://maps.googleapis.com/maps/api/js"></script>

body の内容を見ます。body の中には、 <ons-sliding-menu> 要素が、1 つ置かれています。こちらが、アプリ内で使用されている Onsen UI 要素の root になります。また、ここでは、属性をいくつか設定します。 var="slidingMenu" は、AngularJS 内部の 双方向 データ バインディング ( two way data binding ) の設定です。menu-page="menu.html" は、スライディングメニュー用の HTML ページです。main-page="map.html" は、最初に表示するメインページです。また、上述のように、デフォルトでは、メニューのスワイプ表示はできません ( swipeable="false" )。

  1. <!DOCTYPE HTML>
  2. <html ng-app="myApp">
  3.     <head>
  4.         <meta charset="utf-8">
  5.         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
  6.         <script src="components/loader.js"></script>
  7.         <link rel="stylesheet" href="components/loader.css">
  8.         <link rel="stylesheet" href="css/style.css">
  9.         <script type="text/javascript"
  10.             src="https://maps.googleapis.com/maps/api/js">
  11.         </script>
  12.         <script src="scripts/controller.js"></script>
  13.     </head>
  14.     <body>
  15.         <ons-sliding-menu swipeable="false" var="slidingMenu" menu-page="menu.html" main-page="map.html" side="left" type="overlay" max-slide-distance="200px">
  16.         </ons-sliding-menu>
  17.     </body>
  18. </html>

menu.html

このページには、Monaca 側で自動作成した、<ons-sliding-menu> 要素用のコードが記述されています。<ons-list> が記述され、その中には、2 つの <ons-list-item> が置かれています。この 2 つの <ons-list-item> を使用して、map.html と settings.html へのページ遷移を、それぞれ行っています。これ以外のページも追加したい場合には、適宜、<ons-list-item> を追加して、ページ遷移を行います。

  1. <ons-page style="background-color: white">
  2.     <ons-list>
  3.         <ons-list-item
  4.             modifier="tappable" class="list__item__line-height"
  5.             onclick="slidingMenu.setMainPage('map.html', {closeMenu: true})">
  6.             <i class="fa fa-home fa-lg" style="color: #666"></i>
  7.             &nbsp; Map
  8.         </ons-list-item>
  9.         <ons-list-item
  10.             modifier="tappable" class="list__item__line-height"
  11.             onclick="slidingMenu.setMainPage('settings.html', {closeMenu: true})">
  12.             <i class="fa fa-gear fa-lg" style="color: #666"></i>
  13.             &nbsp; Settings
  14.         </ons-list-item>
  15.     </ons-list>
  16. </ons-page>

map.html

こちらが、アプリの核となるページです。地図本体と複数の Onsen UI 要素から構成されています。ページ上部で、コントローラーの宣言 ( ng-controller="MapController" ) をしています。コントローラーの宣言は、root 要素で通常行われるので、それ以降の要素は、その影響 ( 設定 ) 下に置かれることが、一目でわかります。

また、その直下には、<ons-toolbar> が設定され、ページのタイトル部の設定と <ons-toolbar-button> が記述されています。<ons-toolbar-button> には、スライディングメニューの開閉を行う記述をします。このため、<ons-toolbar> 内で、別のコントローラー ( ng-controller="SlidingMenuController" ) を宣言しています。

残りのコードは、地図、検索ボックス、2 個のボタン ( <ons-button> ) を表示するためのものです。

  1. <ons-navigator ng-controller="MapController">
  2.    <ons-page>
  3.         <ons-toolbar fixed-style ng-controller="SlidingMenuController">
  4.             <div class="left">
  5.                 <ons-toolbar-button ng-click="slidingMenu.toggleMenu()"><ons-icon icon="bars"></ons-icon></ons-toolbar-button>
  6.             </div>
  7.             <div class="center">Map</div>
  8.         </ons-toolbar>
  9.  
  10.         <div id="map_canvas"></div>
  11.  
  12.         <p class="par-search">
  13.             <input type="search" class="search-input search-input-map">
  14.         </p>
  15.  
  16.         <p class="par-buttons">
  17.             <ons-button  class="btn-delete" ng-click="deleteAllMarkers()">
  18.                 Delete all markers        
  19.             </ons-button>
  20.             <ons-button class="btn-distance" ng-click="calculateDistance()">
  21.                 Distance      
  22.             </ons-button>
  23.         </p>   
  24.     </ons-page>
  25. </ons-navigator>

結論

Onsen UI、Google Maps JavaScript API v3、AngularJS の使用方法を、このサンプルアプリでお見せしました。
このサンプルアプリをひな形として、後は、自由にカスタマイズしてください。Google Maps API では、上述以外でも、たくさんの機能を提供しています。
自作する前に、Google Maps API の詳細を読み、使用できるものがないか、確認しましょう。