[これは無償の"NerdDinner"アプリケーション・チュートリアルのステップ11で、ASP.NET MVCを使用して、小さいながらも完全なWebアプリケーションを構築する手順を紹介しています。]
ここではAJAXのマッピング・サポートを統合して、アプリケーションをもう少しビジュアル的に良くしていきます。これにより、夕食会を作成、編集、一覧しているユーザーは、その夕食会の場所を画像で見られるようになります。
マップ・パーシャル・ビューを作成
アプリケーション内の数カ所でマッピング機能を使用していきます。コードをDRYに保つために、共通のマッピング機能を1つのパーシャル・テンプレート内にカプセル化し、複数のコントローラ・アクションやビューで再利用できるようにします。このパーシャル・ビューの名前を“map.ascx”とし、\Views\Dinnersディレクトリ内に作成しましょう。
\Views\Dinnersディレクトリ上で右クリックし、[Add]−[View]メニュー・コマンドを選択するとmap.ascxパーシャルが作成できます。そのビューの名前を“Map.ascx”にし、パーシャル・ビューとしてチェックし、強く型付けされた“Dinner”モデル・オブジェクトにそれを引き渡すことを示します。
|
図1 |
|
“Add”ボタンをクリックすると、パーシャル・テンプレートが作成されます。そして、Map.ascxファイルが以下のような内容となるように更新します。
<script src="http://dev.virtualearth.net/mapcontrol/mapcontrol.ashx v=6.2" type="text/javascript"></script>
<script src="/Scripts/Map.js" type="text/javascript"></script>
<div id="theMap">
</div>
<script type="text/javascript">
$(document).ready(function() {
var latitude = <%=Model.Latitude%>;
var longitude = <%=Model.Longitude%>;
if ((latitude == 0) || (longitude == 0))
LoadMap();
else
LoadMap(latitude, longitude, mapLoaded);
});
function mapLoaded() {
var title = "<%=Html.Encode(Model.Title) %>";
var address = "<%=Html.Encode(Model.Address) %>";
LoadPin(center, title, address);
map.SetZoomLevel(14);
}
</script>
|
|
最初の<script>参照は、Microsoft Virtual Earth 6.2マッピング・ライブラリを指しています。2つ目の<script>参照は、map.jsファイルを指しています。これは間もなく作成するファイルで、共通のJavaScriptマッピングのロジックをカプセル化します。<div id="theMap">要素はHTMLコンテナで、Virtual Earthが地図をホストするために使用します。
そして、このビュー特有の2つのJavaScript関数を持った<script>ブロックを埋め込みます。最初の関数は、jQueryを使用して、ページでのクライアント側スクリプトの実行準備が完了したときに実行される関数と結び付けます。これは、Virtual Earthのマッピング・コントロールをロードするために、Map.jsスクリプト・ファイル内で定義しているLoadMapヘルパー関数を呼び出します。2つ目の関数は、地図に場所を示すためのピンを追加するコールバック・イベント・ハンドラです。
JavaScriptにマッピングしたい夕食会の緯度と経度を埋め込むために、クライアント側スクリプト・ブロック内でサーバ側の<%= %>ブロックをどのように使用しているかをご確認ください。これはクライアント側のスクリプトで使用される動的な値を出力するのに便利なテクニックです(値を取得するために、サーバへの別のAJAXの呼び出しを要求せず、これにより高速化できます)。<%= %>ブロックは、ビューがサーバ上で描画されているときに実行されます。そして、そのHTML出力は、最終的に埋め込まれたJavaScriptの値になります(例えば、var latitude = 47.64312;)。
Map.jsユーティリティ・ライブラリを作成
地図のためのJavaScript機能をカプセル化するために使用するMap.jsファイルを作りましょう(そして、上記のLoadMapとLoadPinメソッドを実装します)。これを行うには、プロジェクト内の\Scriptsディレクトリ上で右クリックし、その後“[追加]−[新しい項目]”メニュー・コマンドを選択してJScriptファイルを選択し、“Map.js”という名前を付けます。
以下は、Map.jsファイルに追加するJavaScriptコードです。これはVirtual Earthと対話して、地図を表示し、そこへ夕食会の場所を示すピンを追加します。
var map = null;
var points = [];
var shapes = [];
var center = null;
function LoadMap(latitude, longitude, onMapLoaded) {
map = new VEMap('theMap');
options = new VEMapOptions();
options.EnableBirdseye = false;
// Makes the control bar less obtrusize.
map.SetDashboardSize(VEDashboardSize.Small);
if (onMapLoaded != null)
map.onLoadMap = onMapLoaded;
if (latitude != null && longitude != null) {
center = new VELatLong(latitude, longitude);
}
map.LoadMap(center, null, null, null, null, null, null, options);
}
function LoadPin(LL, name, description) {
var shape = new VEShape(VEShapeType.Pushpin, LL);
//Make a nice Pushpin shape with a title and description
shape.SetTitle("<span class=\"pinTitle\"> " + escape(name) + "</span>");
if (description !== undefined) {
shape.SetDescription("<p class=\"pinDetails\">" +
escape(description) + "</p>");
}
map.AddShape(shape);
points.push(LL);
shapes.push(shape);
}
function FindAddressOnMap(where) {
var numberOfResults = 20;
var setBestMapView = true;
var showResults = true;
map.Find("", where, null, null, null,
numberOfResults, showResults, true, true,
setBestMapView, callbackForLocation);
}
function callbackForLocation(layer, resultsArray, places,
hasMore, VEErrorMessage) {
clearMap();
if (places == null)
return;
//Make a pushpin for each place we find
$.each(places, function(i, item) {
description = "";
if (item.Description !== undefined) {
description = item.Description;
}
var LL = new VELatLong(item.LatLong.Latitude,
item.LatLong.Longitude);
LoadPin(LL, item.Name, description);
});
//Make sure all pushpins are visible
if (points.length > 1) {
map.SetMapView(points);
}
//If we've found exactly one place, that's our address.
if (points.length === 1) {
$("#Latitude").val(points[0].Latitude);
$("#Longitude").val(points[0].Longitude);
}
}
function clearMap() {
map.Clear();
points = [];
shapes = [];
}
|
|
CreateおよびEditフォームと地図の統合
既存のCreateとEditのシナリオに、地図のサポートを統合しましょう。よい知らせは、これが非常に簡単にでき、コントローラのコードは何も変更する必要がないということです。CreateとEditビューは夕食会のフォームUIを実装するために、共通の“DinnerForm”パーシャル・ビューを共有しているので、1カ所で地図を追加すれば、CreateとEditの両方のシナリオでそれが使用できます。
必要なことは、\Views\Dinners\DinnerForm.ascxパーシャル・ビューを開いて、新しい地図のパーシャルを含むように更新するだけです。以下は地図が追加された後の、更新されたDinnerFormの様子です(注:HTMLフォーム要素は簡潔性のために以下のコード・スニペットから省略しています)。
<%= Html.ValidationSummary() %>
<% using (Html.BeginForm()) { %>
<fieldset>
<div id="dinnerDiv">
<p>
[HTMLフォーム要素は簡潔にするために省略]
</p>
<p>
<input type="submit" value="Save"/>
</p>
</div>
<div id="mapDiv">
<%Html.RenderPartial("Map", Model.Dinner); %>
</div>
</fieldset>
<script type="text/javascript">
$(document).ready(function() {
$("#Address").blur(function(evt) {
$("#Latitude").val("");
$("#Longitude").val("");
var address = jQuery.trim($("#Address").val());
if (address.length < 1)
return;
FindAddressOnMap(address);
});
});
</script>
<% } %>
|
|
上記のDinnerFormパーシャルは、そのモデルの型として“DinnerFormViewModel”型のオブジェクトを取ります(これは、Dinnerオブジェクトと、国のドロップダウンリストとひも付けるSelectListの両方を必要とするためです)。Mapパーシャルはモデル型として“Dinner”型のオブジェクトだけが必要であるため、そのMapパーシャルを描画するときには、DinnerFormViewModelのDinnerサブ・プロパティだけを引き渡します。
<% Html.RenderPartial("Map", Model.Dinner); %>
|
|
パーシャルに追加したJavaScript関数は、jQueryを使用して、“blur”イベントを“Address”HTMLテキストボックスにアタッチしています。ユーザーがテキストボックスでクリックあるいはタブ移動したときに発生する“focus”イベントについて聞いたことがあると思います。この反対が“blur”イベントで、ユーザーがテキストボックスから抜けたときに発生するイベントです。上記のイベント・ハンドラは、これが発生したときに緯度と経度のテキストボックスの値をクリアし、地図上の新しい住所の位置をプロットします。map.jsファイルに定義したコールバックのイベント・ハンドラはその後、与えられた住所に基づいてVirtual Earthから返された値を使用して、フォーム上の緯度と経度のテキストボックスを更新します。
いまアプリケーションを再実行して、“Host Dinner”タブをクリックすると、標準の夕食会フォーム要素があるデフォルトの地図が表示されます。
|
図2 |
|
住所を入力しタブ移動すると、地図は動的に場所の表示を更新して、イベント・ハンドラが、その緯度と経度のテキストボックスとその場所の値をひも付けます。
|
図3 |
|
もし新しい夕食会を保存し、それを編集のために再度開いた場合、そのページがロードされたときに、その地図の場所が表示されます。
|
図4 |
|
住所フィールドが変更されるたびに、地図と緯度/経度の座標が更新されます。
これで地図が夕食会の場所を表示するので、その緯度と経度のフォームのフィールドを、通常表示のテキストボックスから非表示要素に変更することもできます。(住所が入力されるたびに、地図が自動的にそれらを更新するため)。これを行うには、Html.TextBox HTMLヘルパーを使用している個所をHtml.Hiddenヘルパー・メソッドに変更します。
<p>
<%= Html.Hidden("Latitude", Model.Dinner.Latitude)%>
<%= Html.Hidden("Longitude", Model.Dinner.Longitude)%>
</p>
|
|
これでフォームがよりユーザーフレンドリーになり、生の緯度と経度の表示をしないで済みます(まだデータベースには各夕食会と併せて保存しています)。
|
図5 |
|
Detailsビューと地図を統合
CreateとEditのシナリオで地図を統合できたので、Detailsのシナリオでも、統合しましょう。必要なことは、<% Html.RenderPartial("map"); %>をDetailsビューで呼び出すだけです。
以下は(地図が統合された)完全なDetailsビューのソース・コードです。
<asp:Content ID="Title" ContentPlaceHolderID="TitleContent"runat="server">
<%= Html.Encode(Model.Title) %>
</asp:Content>
<asp:Content ID="details" ContentPlaceHolderID="MainContent" runat="server">
<div id="dinnerDiv">
<h2><%=Html.Encode(Model.Title) %></h2>
<p>
<strong>When:</strong>
<%=Model.EventDate.ToShortDateString() %>
<strong>@</strong>
<%=Model.EventDate.ToShortTimeString() %>
</p>
<p>
<strong>Where:</strong>
<%=Html.Encode(Model.Address) %>,
<%=Html.Encode(Model.Country) %>
</p>
<p>
<strong>Description:</strong>
<%=Html.Encode(Model.Description) %>
</p>
<p>
<strong>Organizer:</strong>
<%=Html.Encode(Model.HostedBy) %>
(<%=Html.Encode(Model.ContactPhone) %>)
</p>
<%Html.RenderPartial("RSVPStatus"); %>
<%Html.RenderPartial("EditAndDeleteLinks"); %>
</div>
<div id="mapDiv">
<%Html.RenderPartial("map"); %>
</div>
</asp:Content>
|
|
いまユーザーが/Dinners/Details/[id]のURLを訪れると、その夕食会の詳細および地図上でのその夕食会の場所(押しピンが配置されていて、そこにマウスを当てると、その夕食会のタイトルと住所が表示されます)が表示され、それに対するRSVPのAJAXリンクもあります。
|
図6 |
|
データベースとリポジトリでの位置検索を実装
AJAX実装を完了するために、アプリケーションのホームページに地図を追加して、ユーザーが画像により近くの夕食会を検索できるようにしましょう。
|
図7 |
|
まず夕食会の位置ベースの範囲検索を効率的に実行するために、データベースとデータ・リポジトリ層内でのサポートを実装します。これを実装するのに、新しいSQL Server 2008の空間情報機能 を使用できます。もしくは、SQL関数を使い、Gary Dryden氏がここ(http://www.codeproject.com/KB/cs/distancebetweenlocations.aspx)で記事にしているアプローチか、Rob Conery氏がここ(http://blog.wekeroad.com/2007/08/30/linq-and-geocoding/)でLINQ to SQLとの使用についてブログ投稿しているアプローチを利用できます。
この方法を実装するために、Visual Studioで“サーバ・エクスプローラ”を開き、NerdDinnerデータベースを選択し、その下の“関数(Function)”というサブ・ノード上で右クリックして“スカラ値関数(Scalar-valued Function)”の新規作成を選択します。
|
図8 |
|
そして以下のDistanceBetween関数をペーストします。
CREATE FUNCTION [dbo].[DistanceBetween](@Lat1 as real,
@Long1 as real, @Lat2 as real, @Long2 as real)
RETURNS real
AS
BEGIN
DECLARE @dLat1InRad as float(53);
SET @dLat1InRad = @Lat1 * (PI()/180.0);
DECLARE @dLong1InRad as float(53);
SET @dLong1InRad = @Long1 * (PI()/180.0);
DECLARE @dLat2InRad as float(53);
SET @dLat2InRad = @Lat2 * (PI()/180.0);
DECLARE @dLong2InRad as float(53);
SET @dLong2InRad = @Long2 * (PI()/180.0);
DECLARE @dLongitude as float(53);
SET @dLongitude = @dLong2InRad - @dLong1InRad;
DECLARE @dLatitude as float(53);
SET @dLatitude = @dLat2InRad - @dLat1InRad;
/* Intermediate result a. */
DECLARE @a as float(53);
SET @a = SQUARE (SIN (@dLatitude / 2.0)) + COS (@dLat1InRad)
* COS (@dLat2InRad)
* SQUARE(SIN (@dLongitude / 2.0));
/* Intermediate result c (great circle distance in Radians). */
DECLARE @c as real;
SET @c = 2.0 * ATN2 (SQRT (@a), SQRT (1.0 - @a));
DECLARE @kEarthRadius as real;
/* SET kEarthRadius = 3956.0 miles */
SET @kEarthRadius = 6376.5; /* kms */
DECLARE @dDistance as real;
SET @dDistance = @kEarthRadius * @c;
return (@dDistance);
END
|
|
その後、SQL Serverに新しいテーブル値関数を新規作成し、“NearestDinners”という名前にします。
|
図9 |
|
この“NearestDinners”テーブル値関数は、DistanceBetweenヘルパー関数を使用して、指定した緯度と経度から100マイルの範囲にあるすべての夕食会を返します。
CREATE FUNCTION [dbo].[NearestDinners]
(
@lat real,
@long real
)
RETURNS TABLE
AS
RETURN
SELECT Dinners.DinnerID
FROM Dinners
WHERE dbo.DistanceBetween(@lat, @long, Latitude, Longitude) <100
|
|
この関数を呼び出すために、まず\Modelsディレクトリ内のNerdDinner.dbmlファイルをダブルクリックしてLINQ to SQLデザイナを開きます。
|
図10 |
|
その後、LINQ to SQLデザイナ上にNearestDinnersとDistanceBetween関数をドラッグします。これによりLINQ to SQL NerdDinnerDataContextクラス上にメソッドとして追加されます。
|
図11 |
|
その後、特定の位置から100マイルの範囲内にある未開催の夕食会を返すNearestDinner関数を使用する、DinnerRepositoryクラス上の“FindByLocation”クエリ・メソッドを公開します。
public IQueryable<Dinner> FindByLocation(float latitude, float longitude) {
var dinners = from dinner in FindUpcomingDinners()
join i in db.NearestDinners(latitude, longitude)
on dinner.DinnerID equals i.DinnerID
select dinner;
return dinners;
}
|
|
JSONベースのAJAX Searchアクション・メソッドの実装
これからコントローラのアクション・メソッドを実装していきます。これは、地図をひも付けるために使用できる夕食会データの一覧を返す、新しいFindByLocationリポジトリ・メソッドを利用します。このアクション・メソッドは、JSON(JavaScript Object Notation)フォーマットでその夕食会データを返すようにします。そうすると、簡単にクライアント上でJavaScriptを使用して操作できます。
これを実装するために、\Controllersディレクトリ上で右クリックし、[Add]−[Controller]メニュー・コマンドを選択して、新しい“SearchController”クラスを作成します。そして、以下のように、新しいSearchControllerクラスの中に“SearchByLocation”アクション・メソッドを実装します。
public class JsonDinner {
public int DinnerID { get; set; }
public string Title { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
public string Description { get; set; }
public int RSVPCount { get; set; }
}
public class SearchController : Controller {
DinnerRepository dinnerRepository = new DinnerRepository();
//
// AJAX: /Search/SearchByLocation
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult SearchByLocation(float longitude, float latitude) {
var dinners = dinnerRepository.FindByLocation(latitude,longitude);
var jsonDinners = from dinner in dinners
select new JsonDinner {
DinnerID = dinner.DinnerID,
Latitude = dinner.Latitude,
Longitude = dinner.Longitude,
Title = dinner.Title,
Description = dinner.Description,
RSVPCount = dinner.RSVPs.Count
};
return Json(jsonDinners.ToList());
}
}
|
|
SearchControllerのSearchByLocationアクション・メソッドは、近くの夕食会一覧を取得するDinnerRespository上のFindByLocationメソッドを内部的に呼び出します。クライアントにはDinnerオブジェクトを直接返すのではなく、代わりにJsonDinnerオブジェクトを返します。JsonDinnerクラスは、夕食会のプロパティのサブセットを公開します(例えば、セキュリティの理由から、夕食会にRSVPされている人の名前を公開しません)。また、夕食会には存在しないRSVPCountプロパティも含まれています。これは特定の夕食会に関連付いているRSVPオブジェクトの数をカウントし、動的に計算されるものです。
その後、JSONベースの接続フォーマットを使用して一連の夕食会を返すために、コントローラ基本クラスにあるJSONヘルパー・メソッドを使用します。JSONは単純なデータ構造の表すための標準的なテキスト・フォーマットです。以下はアクション・メソッドから返されたときに、2つのJsonDinnerオブジェクトのJSONフォーマット化されたリストがどのようになっているかを示した例です。
{"DinnerID":54,"Title":"Another Dinner","Latitude":47.632546,"Longitude":-122.21201,"Description":"Dinner with Friends","RSVPCount":3}]
|
|
jQueryを使用したJSONベースのAJAXメソッドの呼び出し
SearchControllerのSearchByLocationアクション・メソッドを使用して、NerdDinnerアプリケーションのホームページを更新する準備ができました。これを行うには、/Views/Home/Index.aspxビュー・テンプレートを開いて、テキストボックス、検索ボタン、地図、dinnerListという名前の<div>要素を追加して更新します。
<h2>Find a Dinner</h2>
<div id="mapDivLeft">
<div id="searchBox">
Enter your location: <%=Html.TextBox("Location") %>
<input id="search" type="submit" value="Search"/>
</div>
<div id="theMap">
</div>
</div>
<div id="mapDivRight">
<div id="dinnerList"></div>
</div>
|
|
そして、2つのJavaScript関数をページに追加します。
<script type="text/javascript">
$(document).ready(function() {
LoadMap();
});
$("#search").click(function(evt) {
var where = jQuery.trim($("#Location").val());
if (where.length < 1)
return;
FindDinnersGivenLocation(where);
});
</script>
|
|
最初のJavaScript関数は、ページが最初にロードされたときに地図をロードします。2つ目のJavaScript関数は、検索ボタン上のJavaScriptのクリック・イベント・ハンドラと接続しています。そのボタンが押されると、Map.jsファイルに追加しようとしているJavaScript関数のFindDinnersGivenLocation()を呼び出します。
function FindDinnersGivenLocation(where) {
map.Find("", where, null, null, null, null, null, false,
null, null, callbackUpdateMapDinners);
}
|
|
このFindDinnersGivenLocation関数は、入力された位置を中央にするために、Virtual Earthコントロール上でmap.Find()を呼び出します。Virtual Earthの地図サービスから戻ってきたとき、map.Findメソッドは末尾の引数として引き渡しているcallbackUpdateMapDinnersコールバック・メソッドを実行します。
callbackUpdateMapDinnersメソッドは、実際の作業が完了したときに呼ばれます。これは、新しく中央となった地図の緯度と経度を渡し、SearchControllerのSearchByLocationアクション・メソッドに対してAJAX呼び出しを実行するために、jQueryの$.postヘルパー・メソッドを使用します。$.postヘルパー・メソッドが完了したときに呼び出されるインライン関数も定義しており、SearchByLocationアクション・メソッドから返されたJSONフォーマット化された夕食会の結果は、“dinners”という変数を使って引き渡されます。その後、返された夕食会でforeachを行い、その夕食会の緯度と経度やそのほかのプロパティを使用して、地図上に新しいピンを追加します。また地図の右側では、夕食会のエントリーをHTMLリストに追加します。そして、押しピンとそのHTMLリストに対してホバー・イベントを設定することにより、それらの上にマウスを乗せると、その夕食会の詳細が表示されるようになります。
function callbackUpdateMapDinners(layer, resultsArray, places, hasMore, VEErrorMessage) {
$("#dinnerList").empty();
clearMap();
var center = map.GetCenter();
$.post("/Search/SearchByLocation", { latitude: center.Latitude,
longitude: center.Longitude },
function(dinners) {
$.each(dinners, function(i, dinner) {
var LL = new VELatLong(dinner.Latitude,
dinner.Longitude, 0, null);
var RsvpMessage = "";
if (dinner.RSVPCount == 1)
RsvpMessage = "" + dinner.RSVPCount + "RSVP";
else
RsvpMessage = "" + dinner.RSVPCount + "RSVPs";
// Add Pin to Map
LoadPin(LL, '<a href="/Dinners/Details/' + dinner.DinnerID + '">'
+ dinner.Title + '</a>',
"<p>" + dinner.Description + "</p>" + RsvpMessage);
//Add a dinner to the <ul> dinnerList on the right
$('#dinnerList').append($('<li/>')
.attr("class", "dinnerItem")
.append($('<a/>').attr("href",
"/Dinners/Details/" + dinner.DinnerID)
.html(dinner.Title))
.append(" ("+RsvpMessage+")"));
});
// Adjust zoom to display all the pins we just added.
map.SetMapView(points);
// Display the event's pin-bubble on hover.
$(".dinnerItem").each(function(i, dinner) {
$(dinner).hover(
function() { map.ShowInfoBox(shapes[i]); },
function() { map.HideInfoBox(shapes[i]); }
);
});
}, "json");
|
|
いまアプリケーションを実行してホームページを訪れると、地図が一緒に表示されます。町の名前を入力すると、地図はそれに近い未開催の夕食会を表示します。
|
図12 |
|
夕食会の上にマウスを乗せると、その詳細が表示されます。
バブル内または右側のHTMLリスト上にある夕食会のタイトルをクリックすると、その夕食会に導かれ、必要ならばそれに対してRSVPできます。
|
図13 |
|
次のステップ
これでNerdDinnerアプリケーションのすべてのアプリケーション機能が実装できました。それでは、自動化された単体テストを行う方法を見てみましょう。
[注:NerdDinnerアプリケーションの完成版はhttp://nerddinner.codeplex.com/からダウンロードできます。]