AngularJSとOnsen UI で作るGoogle Mapsのサンプルアプリ
今回の記事は、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 では、このような形式でエンコード化が行われているため、この変換処理が必要となります。この処理には、以下のような関数を使用します。
$scope.overlay.getProjection().fromContainerPixelToLatLng(point);
地図を入れる div の左上の地点に、座標の原点があります ( 端末の画面の左上ではありません )。これが、Y 座標の取得時に、(-) 44px 分だけ、差し引く理由です ( 44px は、画面上部に置かれた <ons-toolbar> 要素の高さです )。座標の変換後、地図上に、マーカーを表示できます ( マーカー用の配列を使用 )。
次に、マーカーに対するクリック/タップに備えて、リスナーを設定します。マーカーが押された場合、ons.notification.confirm 要素を使用して、マーカーを削除するか否か、ユーザー側に確認します。次に、ユーザーの選択肢に応じて、ons.notification.alert 要素を表示します。
// controller.js
(function() {
var app = angular.module('myApp', ['onsen']);
//Sliding menu controller, swiping management
app.controller('SlidingMenuController', function($scope){
$scope.checkSlidingMenuStatus = function(){
$scope.slidingMenu.on('postclose', function(){
$scope.slidingMenu.setSwipeable(false);
});
$scope.slidingMenu.on('postopen', function(){
$scope.slidingMenu.setSwipeable(true);
});
};
$scope.checkSlidingMenuStatus();
});
//Map controller
app.controller('MapController', function($scope, $timeout){
$scope.map;
$scope.markers = [];
$scope.markerId = 1;
//Map initialization
$timeout(function(){
var latlng = new google.maps.LatLng(35.7042995, 139.7597564);
var myOptions = {
zoom: 8,
center: latlng,
mapTypeId: google.maps.MapTypeId.ROADMAP
};
$scope.map = new google.maps.Map(document.getElementById("map_canvas"), myOptions);
$scope.overlay = new google.maps.OverlayView();
$scope.overlay.draw = function() {}; // empty function required
$scope.overlay.setMap($scope.map);
$scope.element = document.getElementById('map_canvas');
$scope.hammertime = Hammer($scope.element).on("hold", function(event) {
$scope.addOnClick(event);
});
},100);
//Delete all Markers
$scope.deleteAllMarkers = function(){
if($scope.markers.length == 0){
ons.notification.alert({
message: 'There are no markers to delete!!!'
});
return;
}
for (var i = 0; i < $scope.markers.length; i++) {
//Remove the marker from Map
$scope.markers[i].setMap(null);
}
//Remove the marker from array.
$scope.markers.length = 0;
$scope.markerId = 0;
ons.notification.alert({
message: 'All Markers deleted.'
});
};
$scope.rad = function(x) {
return x * Math.PI / 180;
};
//Calculate the distance between the Markers
$scope.calculateDistance = function(){
if($scope.markers.length < 2){
ons.notification.alert({
message: 'Insert at least 2 markers!!!'
});
}
else{
var totalDistance = 0;
var partialDistance = [];
partialDistance.length = $scope.markers.length - 1;
for(var i = 0; i < partialDistance.length; i++){
var p1 = $scope.markers[i];
var p2 = $scope.markers[i+1];
var R = 6378137; // Earth’s mean radius in meter
var dLat = $scope.rad(p2.position.lat() - p1.position.lat());
var dLong = $scope.rad(p2.position.lng() - p1.position.lng());
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos($scope.rad(p1.position.lat())) * Math.cos($scope.rad(p2.position.lat())) *
Math.sin(dLong / 2) * Math.sin(dLong / 2);
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
totalDistance += R * c / 1000; //distance in Km
partialDistance[i] = R * c / 1000;
}
ons.notification.confirm({
message: 'Do you want to see the partial distances?',
callback: function(idx) {
ons.notification.alert({
message: "The total distance is " + totalDistance.toFixed(1) + " km"
});
switch(idx) {
case 0:
break;
case 1:
for (var i = (partialDistance.length - 1); i >= 0 ; i--) {
ons.notification.alert({
message: "The partial distance from point " + (i+1) + " to point " + (i+2) + " is " + partialDistance[i].toFixed(1) + " km"
});
}
break;
}
}
});
}
};
//Add single Marker
$scope.addOnClick = function(event) {
var x = event.gesture.center.pageX;
var y = event.gesture.center.pageY-44;
var point = new google.maps.Point(x, y);
console.log(x + " - " + y);
var coordinates = $scope.overlay.getProjection().fromContainerPixelToLatLng(point);
console.log(coordinates.lat + ", " + coordinates.lng);
var marker = new google.maps.Marker({
position: coordinates,
map: $scope.map
});
marker.id = $scope.markerId;
$scope.markerId++;
$scope.markers.push(marker);
$timeout(function(){
//Creation of the listener associated to the Markers click
google.maps.event.addListener(marker, "click", function (e) {
ons.notification.confirm({
message: 'Do you want to delete the marker?',
callback: function(idx) {
switch(idx) {
case 0:
ons.notification.alert({
message: 'You pressed "Cancel".'
});
break;
case 1:
for (var i = 0; i < $scope.markers.length; i++) {
if ($scope.markers[i].id == marker.id) {
//Remove the marker from Map
$scope.markers[i].setMap(null);
//Remove the marker from array.
$scope.markers.splice(i, 1);
}
}
ons.notification.alert({
message: 'Marker deleted.'
});
break;
}
}
});
});
},1000);
};
});
})();
$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 が大きすぎでも、使いにくいマップとなります。
#map_canvas {
position: absolute;
width:100%;
height: 100%;
margin: 0;
padding: 0;
z-index: -1
}
.search-input-map{
width:60%;
z-index:1;
margin-left: auto;
margin-right:auto;
}
.par-search{
margin-top:10%
}
.par-buttons{
position: absolute;
text-align: center;
width:100%;
bottom:0px
}
.btn-delete{
z-index:1
}
.btn-distance{
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 キーの取得・記述方法に関しては、こちらのリンクをご確認ください。
以下のタグを使用して、地図を表示します。
<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" )。
<!DOCTYPE HTML>
<html ng-app="myApp">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<script src="components/loader.js"></script>
<link rel="stylesheet" href="components/loader.css">
<link rel="stylesheet" href="css/style.css">
<script type="text/javascript"
src="https://maps.googleapis.com/maps/api/js">
</script>
<script src="scripts/controller.js"></script>
</head>
<body>
<ons-sliding-menu swipeable="false" var="slidingMenu" menu-page="menu.html" main-page="map.html" side="left" type="overlay" max-slide-distance="200px">
</ons-sliding-menu>
</body>
</html>
menu.html
このページには、Monaca 側で自動作成した、<ons-sliding-menu> 要素用のコードが記述されています。<ons-list> が記述され、その中には、2 つの <ons-list-item> が置かれています。この 2 つの <ons-list-item> を使用して、map.html と settings.html へのページ遷移を、それぞれ行っています。これ以外のページも追加したい場合には、適宜、<ons-list-item> を追加して、ページ遷移を行います。
<ons-page style="background-color: white">
<ons-list>
<ons-list-item
modifier="tappable" class="list__item__line-height"
onclick="slidingMenu.setMainPage('map.html', {closeMenu: true})">
<i class="fa fa-home fa-lg" style="color: #666"></i>
Map
</ons-list-item>
<ons-list-item
modifier="tappable" class="list__item__line-height"
onclick="slidingMenu.setMainPage('settings.html', {closeMenu: true})">
<i class="fa fa-gear fa-lg" style="color: #666"></i>
Settings
</ons-list-item>
</ons-list>
</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> ) を表示するためのものです。
<ons-navigator ng-controller="MapController">
<ons-page>
<ons-toolbar fixed-style ng-controller="SlidingMenuController">
<div class="left">
<ons-toolbar-button ng-click="slidingMenu.toggleMenu()"><ons-icon icon="bars"></ons-icon></ons-toolbar-button>
</div>
<div class="center">Map</div>
</ons-toolbar>
<div id="map_canvas"></div>
<p class="par-search">
<input type="search" class="search-input search-input-map">
</p>
<p class="par-buttons">
<ons-button class="btn-delete" ng-click="deleteAllMarkers()">
Delete all markers
</ons-button>
<ons-button class="btn-distance" ng-click="calculateDistance()">
Distance
</ons-button>
</p>
</ons-page>
</ons-navigator>
結論
Onsen UI、Google Maps JavaScript API v3、AngularJS の使用方法を、このサンプルアプリでお見せしました。
このサンプルアプリをひな形として、後は、自由にカスタマイズしてください。Google Maps API では、上述以外でも、たくさんの機能を提供しています。
自作する前に、Google Maps API の詳細を読み、使用できるものがないか、確認しましょう。