AJAX を使用し、マッピング シナリオを実装する
提供元: Microsoft
これは、無料の "NerdDinner" アプリケーション チュートリアルの手順 11 です。ASP.NET MVC 1 を使用して小規模ながら完全な Web アプリケーションを構築する方法を説明します。
手順 11 では、ディナーを作成、編集、または表示するユーザーがディナーの場所をグラフィカルに確認できるように、AJAX マッピング サポートを NerdDinner アプリケーションに統合する方法を示します。
ASP.NET MVC 3 を使用する場合は、MVC 3 の概要または MVC Music Store に関するチュートリアルに従うことをお勧めします。
NerdDinner の手順 11: AJAX マップを統合する
ここでは、AJAX マッピング サポートを統合することで、このアプリケーションをもう少し視覚的に魅力的なものにします。 これにより、ディナーを作成、編集、または表示しているユーザーは、ディナーの場所をグラフィカルに確認できるようになります。
マップ部分ビューを作成する
アプリケーション内のいくつかの場所でマッピング機能を使用するつもりです。 コードの DRY を維持するために、よく使われるマップ機能を単一の部分テンプレート内にカプセル化し、複数のコントローラー アクションおよびビュー間で再利用できるようにします。 この部分ビューに "map.ascx" という名前を付け、\Views\Dinners ディレクトリ内に作成します。
map.ascx 部分を作成するには、\Views\Dinners ディレクトリを右クリックし、[追加]>[ビュー] メニュー コマンドの順に選択します。 このビューに "Map.ascx" という名前を付け、それを部分ビューとするようにチェックを入れます。さらにそれが厳密に型指定された "Dinner" モデル クラスに渡されるように指定します。
[追加] ボタンをクリックすると、部分テンプレートが作成されます。 次に、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"> 要素は、Virtual Earth でマップをホストするために使用する HTML コンテナーです。
さらに、このビューに固有の 2 つの JavaScript 関数を含んでいる埋め込み <script> ブロックがあります。 最初の関数では、jQuery を使用して、ページにおいてクライアント側スクリプトの実行準備が整ったときに実行される関数を接続します。 これによって呼び出されるのは、Virtual Earth マップ コントロールを読み込むために Map.js スクリプト ファイル内に定義する LoadMap() ヘルパー関数です。 2 番目の関数は、場所を識別するピンをマップに追加するためのコールバック イベント ハンドラーです。
JavaScript にマップする Dinner の緯度と経度を埋め込むためには、クライアント側スクリプト ブロック内でサーバー側 <%= %> ブロックをどのように使用すればよいかに注目してください。 これは、クライアント側スクリプトで使用できる動的な値を出力するのに便利な手法です (サーバーに対し個別に 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 = [];
}
マップを作成および編集フォームと統合する
ここでは、マップ サポートを作成および編集シナリオと統合します。 喜ばしいことに、これは非常に簡単に行うことができ、コントローラー コードに変更を加える必要はありません。 作成および編集ビューでは、ディナー フォーム UI を実装するための共通の "DinnerForm" 部分ビューを共有しているので、マップを 1 か所に追加し、作成および編集の両方のシナリオでそれが使用されるようにすることができます。
必要な作業は、\Views\Dinners\DinnerForm.ascx 部分ビューを開き、それを更新して新しいマップ部分を含めることだけです。 マップを追加すると、更新された DinnerForm は次のようになります (注: 簡潔にするために、以下のコード スニペットから HTML フォーム要素は省略されています)。
<%= Html.ValidationSummary() %>
<% using (Html.BeginForm()) { %>
<fieldset>
<div id="dinnerDiv">
<p>
[HTML Form Elements Removed for Brevity]
</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 の両方が必要であるためです)。 マップ部分にはモデル型として "Dinner" 型のオブジェクトが必要であるだけなので、マップ部分をレンダリングする際は、DinnerFormViewModel の Dinner サブプロパティのみをそれに渡します。
<% Html.RenderPartial("Map", Model.Dinner); %>
その部分に追加済みの JavaScript 関数では、jQuery を使用して "ぼかし" イベントを [アドレス] HTML テキストボックスにアタッチします。 ユーザーがテキスト ボックスをクリックまたはそこへタブ移動したときに発生する "フォーカス" イベントについては、おそらく聞いたことがあるでしょう。 それとは逆に、ユーザーがテキスト ボックスを終了したときに発生するのが "ぼかし" イベントです。 それが発生すると、上記のイベント ハンドラーにより、緯度および経度のテキスト ボックスの値がクリアされ、マップ上の新しいアドレスの場所がプロットされます。 そして、map.js ファイル内で定義したコールバック イベント ハンドラーが、指定されたアドレスに基づいて Virtual Earth から返された値を使用して、フォーム上の経度と緯度のテキスト ボックスを更新します。
ここで、アプリケーションをもう一度実行し、[ディナーをホスト] タブをクリックすると、既定のマップと共に標準の Dinner フォーム要素が表示されます。
アドレスを入力してからタブで移動すると、マップが動的に更新されてその場所が表示され、イベント ハンドラーによって緯度および経度のテキスト ボックスに該当する場所の値が入力されます。
新しいディナーを保存してから、それを編集のためにもう一度開いた場合、ページが読み込まれると、該当するマップの場所が表示されるのがわかります。
アドレス フィールドが変更されるたびに、マップと緯度および経度の座標も更新されます。
マップに Dinner の場所が表示されたので、[緯度] および [経度] フォーム フィールドを表示テキスト ボックスから非表示の要素に変更することもできます (アドレスを入力するたびにマップが自動的に更新されるためです)。 これを行うには、Html.TextBox() HTML ヘルパーの使用から Html.Hidden() ヘルパー メソッドの使用に切り替えます。
<p>
<%= Html.Hidden("Latitude", Model.Dinner.Latitude)%>
<%= Html.Hidden("Longitude", Model.Dinner.Longitude)%>
</p>
これで、フォームは前より少し使いやすくになり、生の緯度および経度は表示されなくなりました (ただし、それらは引き続き各 Dinner とともにデータベースに保存されます)。
マップを詳細ビューと統合する
マップを作成および編集シナリオと統合したので、今度はそれを詳細シナリオとも統合してみましょう。 必要なのは、詳細ビュー内で <% Html.RenderPartial("map"); %> を呼び出すことだけです。
完全な詳細ビュー (マップとの統合による) のソース コードは次のようになります。
<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 に移動すると、ディナーに関する詳細、マップ上でのディナーの場所 (カーソルを合わせるとディナーのタイトルとアドレスを表示するプッシュピンも含めて全部) が表示され、出欠を表明するための AJAX リンクも表示されます。
データベースとリポジトリに場所検索を実装する
AJAX の実装の仕上げとして、アプリケーションのホーム ページにマップを追加します。これにより、ユーザーは近くで催されるディナーをグラフィカルに検索できます。
まず、場所に基づく半径検索を効率的に実行して Dinner を探すためのサポートを、データベースとデータ リポジトリ レイヤー内に実装します。 SQL 2008 の新しい地理空間機能を使用すれば、それを実装することができます。あるいは、Gary Dryden が http://www.codeproject.com/KB/cs/distancebetweenlocations.aspx の記事の中で説明している SQL 関数アプローチを使用することもできます。
この手法を実装するには、Visual Studio 内で "サーバー エクスプローラー" を開き、NerdDinner データベースを選択してから、その下にある "Functions" サブノードを右クリックして、"スカラー値関数" の新規作成を選択します。
次に、以下の 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
次に、"NearestDinners" という名前の新しいテーブル値関数を SQL Server 内に作成します。
この "NearestDinners" テーブル関数では DistanceBetween ヘルパー関数を使用して、それに指定された緯度と経度から 100 マイル以内のすべての Dinner を返します。
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 デザイナーを開きます。
次に、NearestDinners および DistanceBetween 関数を LINQ to SQL デザイナーにドラッグします。すると、それらは LINQ to SQL の NerdDinnerDataContext クラスにメソッドとして追加されます。
次に、DinnerRepository クラス上で "FindByLocation" クエリ メソッドを公開します。これは NearestDinner 関数を使用して、指定された場所から 100 マイル以内で今後催される Dinner を返します。
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 検索アクション メソッドを実装する
ここでは、新しい FindByLocation() リポジトリ メソッドを利用するコントローラー アクション メソッドを実装して、マップに値を設定するのに使用できる Dinner データの一覧を返すようにします。 このアクション メソッドから返される Dinner データは JSON (JavaScript Object Notation) 形式であるため、クライアント上で JavaScript を使用して簡単に操作できます。
これを実装するには、\Controllers ディレクトリを右クリックし、[追加]>[コントローラー] メニュー コマンドの順に選択して、新しい "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 アクション メソッドは、DinnerRepository 上で FindByLocation メソッドを内部的に呼び出して、近くで催されるディナーの一覧を取得します。 ただし、Dinner オブジェクトをクライアントに直接返すのではなく、JsonDinner オブジェクトを返します。 この JsonDinner クラスによって、Dinner のプロパティのサブセットが公開されます (たとえば、セキュリティ上の理由から、ディナーへの出欠を表明した人の名前は公開されません)。 また、これには Dinner 上に存在しない RSVPCount プロパティも含まれます。これは、特定のディナーに関連付けられた RSVP オブジェクトの数をカウントすることによって動的に計算されます。
次に、JSON ベースのワイヤ形式を使用して一連のディナーを返すために、Controller 基底クラス上の Json() ヘルパー メソッドを使用します。 JSON は、シンプルなデータ構造を表現するための標準的なテキスト形式です。 2 つの JsonDinner オブジェクトの JSON 形式のリストは、アクション メソッドから返される場合、次のようになります。
[{"DinnerID":53,"Title":"Dinner with the Family","Latitude":47.64312,"Longitude":-122.130609,"Description":"Fun dinner","RSVPCount":2},
{"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 ファイルに追加される FindDinnersGivenLocation() JavaScript 関数が呼び出されます。
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() メソッドは、実際の動作が行われる場所です。 jQuery の $.post() ヘルパー メソッドを使用して、SearchController の SearchByLocation() アクション メソッドの AJAX 呼び出しを実行します。このとき、新しく中央に配置されるマップの緯度と経度を渡します。 $.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");
これで、アプリケーションを実行してホーム ページにアクセスすると、マップが表示されます。 都市の名前を入力すると、マップにはその近くで今度催されるディナーが表示されます。
ディナーにカーソルを合わせると、その詳細が表示されます。
バブル内で、または HTML リストの右側で、タイトル [ディナー] をクリックすると、ディナーが表示されます。それに対し、必要に応じて出欠を表明できます。
次の手順
これで、NerdDinner アプリケーションのすべてのアプリケーション機能を実装しました。 次に自動単体テストを有効にする方法を見てみましょう。