DocumentDBのgeospatial機能で地理的検索(位置情報検索)を行う

結構以前からあったものだと思うのですが、Azure DocumentDBの「geospatial(地理空間検索)」機能を使ってみたいと思います。
緯度・経度情報をDocumentDBに保存しておき、以下のような操作を行うことが可能です。

  1. 2点間の距離の算出
  2. 1つの地理的領域が、別のもう1つの領域内に存在するかのチェック
  3. 2つの地理的領域の重なりのチェック

ここでは「地理的領域」という言葉を使用しましたが、DocumentDBでは緯度・経度情報を「点」「ライン」「ポリゴン(領域)」で保持することができます。
これらの緯度・経度情報に対してクエリーをかけることができます。

こんなアプリを作る

本投稿では、上記1番目の「2点間の距離の算出」を利用して以下のようなWebアプリを作成します。

  • 災害時避難施設情報をDocumentDBに保存しておく
  • Webアプリを作成。JavaScriptで現在位置情報を自動取得 → 近隣の避難施設を検索する
  • 避難施設の場所はGooogle Mapsへのリンクを配置することで表示する

画面キャプチャは以下の通りです。

f:id:daigo-knowlbo:20170418235759p:plain

「避難施設を検索」をクリックすると・・・

f:id:daigo-knowlbo:20170418235920p:plain

「地図を表示」をクリックすると・・・Google Mapsが別ウィンドウ表示される。

f:id:daigo-knowlbo:20170418235933p:plain

ソースは以下の github に上げてあります。

github.com

実装の構成は「1ソリューション・3プロジェクト構成」です。
プロジェクト構成は以下の通り。

・CreateEvacuationFacilityDb - DocumentDBデータを作成するコンソールアプリケーションプロジェクト
・EvacuationFacilityLib - DocumentDB操作を行う為のリポジトリクラスとモデルクラスを含むライブラリプロジェクト
・EvacuationFacilityWeb - 災害時避難施設を検索するWebアプリケーションプロジェクト

では以下に、ソースの解説を含めて作成手順を記述いたします。

1. DocumentDBデータを準備する

まず、データソースとなるDocumentDBデータを作成します。

DocumentDB Emulatorを起動

Azure上にDocumentDBを作成してもよいのですが、今回はローカルでお手軽にテスト可能な DocumentDB Emulator を利用します。
DocumentDB Emulatorを起動しておきましょう。タスクトレイにブルーのアイコンが現れますね。

災害時避難施設情報XMLを取得する

災害時避難施設情報は 国土数値情報ダウンロードサービス国土数値情報 避難施設データの詳細 からダウンロードすることができるのでこれを利用します。
平成24年のデータで古い気もしますが、全国の災害時避難施設の名称・住所・緯度経度情報 等々 が得られます。
今回は、データが大きいので、東京 のデータだけを取得しました。
ダウンロードファイルを解凍すると以下のようなイメージです。

f:id:daigo-knowlbo:20170418203917p:plain

この中から「P20-12_13.xml」を利用します。

避難施設情報XMLからDocumentDB保存用データ型に変換

XMLデータをDocumentoDBデータ保存用の型に変換する処理を用意します。
ダウンロードした P20-12_13.xml は以下のようなスキーマです。

// P20-12_13.xml
<?xml version="1.0" encoding="utf-8"?>
<ksj:Dataset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:gml="http://www.opengis.net/gml/3.2" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ksj="http://nlftp.mlit.go.jp/ksj/schemas/ksj-app" gml:id="P20Dataset" xsi:schemaLocation="http://nlftp.mlit.go.jp/ksj/schemas/ksj-app KsjAppSchema-P20-v1_0.xsd">
  <gml:description>国土数値情報 避難施設 インスタンス文書</gml:description>
  <gml:boundedBy>
    <gml:EnvelopeWithTimePeriod srsName="JGD2000 / (B, L)" frame="GC / JST">
      <gml:lowerCorner>20.0 123.0</gml:lowerCorner>
      <gml:upperCorner>46.0 154.0</gml:upperCorner>
      <gml:beginPosition calendarEraName="西暦">1900</gml:beginPosition>
      <gml:endPosition indeterminatePosition="unknown"/>
    </gml:EnvelopeWithTimePeriod>
  </gml:boundedBy>
  <!--図形-->
  <gml:Point gml:id="pt100001">
    <gml:pos>35.686898 139.739538</gml:pos>
  </gml:Point>
  <gml:Point gml:id="pt100002">
    <gml:pos>35.697347 139.760433</gml:pos>
  </gml:Point>
  ... 省略 ...
  <ksj:EvacuationFacilities gml:id="P20_100001">
    <ksj:position xlink:href="#pt100001"/>
    <ksj:administrativeAreaCode codeSpace="AdministrativeAreaCode.xml">13101</ksj:administrativeAreaCode>
    <ksj:name>いきいきプラザ一番町</ksj:name>
    <ksj:address>東京都千代田区一番町12-2</ksj:address>
    <ksj:facilityType>指定避難所、二次避難所</ksj:facilityType>
    <ksj:seatingCapacity>-1</ksj:seatingCapacity>
    <ksj:facilityScale>-1</ksj:facilityScale>
    <ksj:hazardClassification>
      <ksj:Classification>
        <ksj:earthquakeHazard>false</ksj:earthquakeHazard>
        <ksj:tsunamiHazard>false</ksj:tsunamiHazard>
        <ksj:windAndFloodDamage>false</ksj:windAndFloodDamage>
        <ksj:volcanicHazard>false</ksj:volcanicHazard>
        <ksj:other>false</ksj:other>
        <ksj:notSpecified>true</ksj:notSpecified>
      </ksj:Classification>
    </ksj:hazardClassification>
  </ksj:EvacuationFacilities>
  <ksj:EvacuationFacilities gml:id="P20_100002">
    <ksj:position xlink:href="#pt100002"/>
    <ksj:administrativeAreaCode codeSpace="AdministrativeAreaCode.xml">13101</ksj:administrativeAreaCode>
    <ksj:name>お茶の水小学校</ksj:name>
    <ksj:address>東京都千代田区猿楽町1-1-1</ksj:address>
    <ksj:facilityType>指定避難所、地区救援センター</ksj:facilityType>
    <ksj:seatingCapacity>-1</ksj:seatingCapacity>
    <ksj:facilityScale>-1</ksj:facilityScale>
    <ksj:hazardClassification>
      <ksj:Classification>
        <ksj:earthquakeHazard>false</ksj:earthquakeHazard>
        <ksj:tsunamiHazard>false</ksj:tsunamiHazard>
        <ksj:windAndFloodDamage>false</ksj:windAndFloodDamage>
        <ksj:volcanicHazard>false</ksj:volcanicHazard>
        <ksj:other>false</ksj:other>
        <ksj:notSpecified>true</ksj:notSpecified>
      </ksj:Classification>
    </ksj:hazardClassification>
  </ksj:EvacuationFacilities>
  ... 省略 ...
</ksj:Dataset>

上記XMLデータを元に、以下のようなJSONスキーマデータに変換してDocumentDBに保存したいと思います。

{
  "ID": "pt100001",
  "Latitude": 35.686898,
  "Longitude": 139.739538,
  "Location": {
    "type": "Point",
    "coordinates": [
      139.739538,
      35.686898
    ]
  },
  "Name": "いきいきプラザ一番町",
  "Address": "東京都千代田区一番町12-2",
  "FacilityType": "指定避難所、二次避難所"
}

ということで、上記JSONスキーマに該当するモデルクラスとして、以下の EvacuationFacilityInfo クラスを用意します。
「public Point Location」プロパティがポイントで、Point型は「Microsoft.Azure.Documents.Spatial名前空間の型」になります。
この値が地理的データクエリーの対象となります。

// EvacuationFacilityLib\Models\EvacuationFacilityInfo.cs
using Microsoft.Azure.Documents.Spatial;

namespace EvacuationFacilityLib.Models
{
  public class EvacuationFacilityInfo
  {
    // ID
    public string ID { get; set; }
    // 緯度
    public double Latitude { get; set; }
    // 経度
    public double Longitude { get; set; }
    // DocumentDBクエリー用のPoint型
    public Point Location { get; set; }
    // 施設名
    public string Name { get; set; }
    // 住所
    public string Address { get; set; }
    // 施設タイプ
    public string FacilityType { get; set; }
  }
}

XMLデータを EvacuationFacilityInfo オブジェクト配列にコンバートするロジックを実装します(GmlToCustomFormatConvertorクラス)。
実装はごちゃごちゃしていますが、「XPathでクエリーしてモデルクラスに一生懸命変換する」というごり押し実装なので説明は省きます。

// CreateEvacuationFacilityDb\GmlToCustomFormatConvertor.cs
using System.Collections.Generic;
using System.Xml;
using System.Xml.XPath;
using System.Xml.Linq;
using Microsoft.Azure.Documents.Spatial;
using EvacuationFacilityLib.Models;

namespace CreateEvacuationFacilityDb
{
  public class GmlToCustomFormatConvertor
  {
    public static List<EvacuationFacilityInfo> Load(string xmlFilePath)
    {
      var result = new List<EvacuationFacilityInfo>();

      var xDoc = XDocument.Load(xmlFilePath);

      var nsmgr = new XmlNamespaceManager(new NameTable());
      nsmgr.AddNamespace("gml", "http://www.opengis.net/gml/3.2");
      nsmgr.AddNamespace("ksj", "http://nlftp.mlit.go.jp/ksj/schemas/ksj-app");
      nsmgr.AddNamespace("xlink", "http://www.w3.org/1999/xlink"); 

      var points = xDoc.XPathSelectElements("//gml:Point", nsmgr);
      foreach (var point in points)
      {
        var evacuationFacilityInfo = new EvacuationFacilityInfo();

        //var id = point.Attribute("gml:id").Value;
        evacuationFacilityInfo.ID = point.FirstAttribute.Value;
        
        //var pos = point.XPathSelectElement("gml:pos");
        string latitudeAndLongitude = point.Value;
        double dLatitude = double.Parse(latitudeAndLongitude.Split(' ')[0]);
        double dLongitude = double.Parse(latitudeAndLongitude.Split(' ')[1]);
        evacuationFacilityInfo.Latitude = dLatitude;
        evacuationFacilityInfo.Longitude = dLongitude;
        evacuationFacilityInfo.Location = new Point(dLongitude, dLatitude);

        var evacuationFacility = 
          xDoc.XPathSelectElement("//ksj:EvacuationFacilities/ksj:position[@xlink:href='#" + evacuationFacilityInfo.ID + "']", nsmgr).Parent;
        evacuationFacilityInfo.Name = 
          evacuationFacility.XPathSelectElement("ksj:name", nsmgr).Value;
        evacuationFacilityInfo.Address = 
          evacuationFacility.XPathSelectElement("ksj:address", nsmgr).Value;
        evacuationFacilityInfo.FacilityType = 
          evacuationFacility.XPathSelectElement("ksj:facilityType", nsmgr).Value;

        result.Add(evacuationFacilityInfo);
      }

      return result;
    }
  }
}

EvacuationFacilityInfoオブジェクトをDocumentDBに保存

XMLをコンバートして得られたEvacuationFacilityInfoオブジェクトをDocumentDBに保存するリポジトリークラスも作成しましょう。
データベースの作成・コレクションの作成・ドキュメントの保存・ドキュメントの地理的検索まで実装してしまいます。

// EvacuationFacilityInfoRepository.cs
#define LOCAL_DB

using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading.Tasks;

using Microsoft.Azure.Documents;
using Microsoft.Azure.Documents.Client;

using Microsoft.Azure.Documents.Spatial;

using EvacuationFacilityLib.Models;

namespace EvacuationFacilityLib.Repositories
{
  public class EvacuationFacilityInfoRepository
  {
    /// <summary>
    /// 任意のデータベースID
    /// </summary>
    private static readonly string DatabaseId = "EvacuationFacilityInfoDb";

    /// <summary>
    /// 任意のコレクションID
    /// </summary>
    private static readonly string CollectionId = "EvacuationFacilityInfoCollection";

    /// <summary>
    /// エンドポイント
    /// </summary>
#if LOCAL_DB
    private static readonly string EndPoint = "https://localhost:8081/";
#else
    private static readonly string EndPoint = "[your endpoint]";
#endif

    /// <summary>
    /// 認証キー(固定)
    /// </summary>
#if LOCAL_DB
      private static readonly string AuthKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
#else
    private static readonly string AuthKey = "[your auth key]";
#endif

    private static DocumentClient client;


    /// <summary>
    /// EvacuationFacilityInfoオブジェクトを保存します。
    /// </summary>
    public static void SaveEvacuationFacilityInfos(List<EvacuationFacilityInfo> evacuationFacilityInfos)
    {
      foreach (var info in evacuationFacilityInfos)
      {
        client.UpsertDocumentAsync(UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId), info);
      }
    }

    /// <summary>
    /// 緯度経度に対する距離範囲に存在するデータ(避難施設)を取得します。
    /// </summary>
    public static List<EvacuationFacilityInfo> Search(double longitude, double latitude, int distance)
    {
      List<EvacuationFacilityInfo> result = null;

      var query = 
        client.CreateDocumentQuery<EvacuationFacilityInfo>(
          UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId),
          new FeedOptions { MaxItemCount = -1 })
          .Where(i => i.Location.Distance(new Point(longitude, latitude)) < distance);

      result = query.ToList();

      return result;
    }

    /// <summary>
    /// データベース・コレクションの初期化を行います。
    /// </summary>
    public static void Initialize(bool initDatabase = false)
    {
      client = new DocumentClient(new Uri(EndPoint), AuthKey, new ConnectionPolicy { EnableEndpointDiscovery = false });
      if (initDatabase)
      {
        CreateDatabaseIfNotExistsAsync().Wait();
        CreateCollectionIfNotExistsAsync().Wait();
      }
    }

    /// <summary>
    /// 存在しなければデータベースを作成します。
    /// </summary>
    /// <returns></returns>
    private static async Task CreateDatabaseIfNotExistsAsync()
    {
      try
      {
        await client.ReadDatabaseAsync(UriFactory.CreateDatabaseUri(DatabaseId));
      }
      catch (DocumentClientException e)
      {
        if (e.StatusCode == System.Net.HttpStatusCode.NotFound)
        {
          await client.CreateDatabaseAsync(new Database { Id = DatabaseId });
        }
        else
        {
          throw;
        }
      }
      catch
      {
        throw;
      }
    }

    /// <summary>
    /// 存在しなければコレクションを作成します。
    /// </summary>
    /// <returns></returns>
    private static async Task CreateCollectionIfNotExistsAsync()
    {
      try
      {
        await client.ReadDocumentCollectionAsync(UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId));
      }
      catch (DocumentClientException e)
      {
        if (e.StatusCode == System.Net.HttpStatusCode.NotFound)
        {
          // DataType.Point に対して SpatialIndex 付加してコレクションを作成
          await client.CreateDocumentCollectionAsync(
            UriFactory.CreateDatabaseUri(DatabaseId),
            new DocumentCollection { Id = CollectionId, IndexingPolicy = new IndexingPolicy(new SpatialIndex(DataType.Point)) },
            new RequestOptions { OfferThroughput = 1000 });
        }
        else
        {
          throw;
        }
      }
    }
  }
}

DatabaseId / CollectionIdは任意ですが、ここではそれぞれ「EvacuationFacilityInfoDb」「EvacuationFacilityInfoCollection」としました。
EndPointはローカルのDocumentDB Emulatorを使用するため「https://localhost:8081/」となります。AuthKeyもEmulatorである為、固定値となります。
エミュレータではなくAzure DocumentDBを利用する場合には、適切なEndPointとAuthKeyを指定してください(あなたのAzureポータルから確認可能)。
Initialize()メソッドで接続用DocumentClientオブジェクトを作成します。引数initDatabaseがtrueの場合は、データベース・コレクションの作成も行います。
CreateCollectionIfNotExistsAsync()メソッドではコレクションを作成します。ここでポイントがあります。地理的クエリーを行う為に、インデックスポリシーとしてSpatialIndexを指定してます。
SaveEvacuationFacilityInfos()メソッドは、EvacuationFacilityInfoオブジェクト配列をDocumentDBに保存します。
Search()メソッドは、データの中から、引数で指定した緯度経度に対する距離範囲内の位置にある避難施設を検索します。
以下の様に距離範囲のクエリー条件を記述することができます。

.Where(i => i.Location.Distance(new Point(longitude, latitude)) < distance);

Microsoft.Azure.Documents.Spatial.Pointクラスのコンストラクタ引数は、(緯度, 経度)ではなく (経度, 緯度) なのでご注意を。

コンバート及びDocumentDB作成処理の呼び出し

上記で用意した各クラス・メソッドを呼び出して「XMLデータからEvacuationFacilityInfoオブジェクトに変換、さらにDocumentDBに保存」までを行うコンソールアプリ「CreateEvacuationFacilityDb」のMain()メソッドを実装します。

// CreateEvacuationFacilityDb\Program.cs
using System.Collections.Generic;

using EvacuationFacilityLib.Models;
using EvacuationFacilityLib.Repositories;

namespace CreateEvacuationFacilityDb
{
  class Program
  {
    static void Main(string[] args)
    {
      // DocumentDB初期化
      EvacuationFacilityInfoRepository.Initialize(true);
      
      // XMLをロードしてカスタムクラスコレクションに変換
      // xmlのパスは書き換えてください!
      List<EvacuationFacilityInfo> evacuationFacilityInfoList = 
        GmlToCustomFormatConvertor.Load(@"J:\Projects\Github\research\DocumentDB\GIS\P20-12_13.xml");

      // カスタムクラスコレクションをDocumentDBに保存
      EvacuationFacilityInfoRepository.SaveEvacuationFacilityInfos(evacuationFacilityInfoList);
    }

  }
}

読み込むXMLファイルのパスは皆さんの環境に合わせて書き換えてください。
CreateEvacuationFacilityDbコンソールアプリを実行すると、DocumentDBにデータが作成・格納されます。

f:id:daigo-knowlbo:20170419005131p:plain

2. Webアプリを作成する

EvacuationFacilityWebアプリを作成します。

DocumentDB接続を初期化

まずGlobal.asax.csのStartで「EvacuationFacilityInfoRepository.Initialize()」を呼び出し、DocumentClientオブジェクトを初期化します。

// EvacuationFacilityWeb\Global.asax.cs
public class MvcApplication : System.Web.HttpApplication
{
  protected void Application_Start()
  {
    // DocumentDB初期化
    EvacuationFacilityInfoRepository.Initialize();

    AreaRegistration.RegisterAllAreas();
    FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
    RouteConfig.RegisterRoutes(RouteTable.Routes);
    BundleConfig.RegisterBundles(BundleTable.Bundles);
  }

コントローラークラスを実装

コントローラクラスを実装します。
検索画面表示の為のHTTP GET用Index()メソッド、「避難施設を検索」クリック時のHTTP POST用Index()メソッドを実装します。
POST時には検索結果をViewBagにしまって resultビュー を表示します。

// EvacuationFacilityWeb\Controllers\HomeController.cs
using System.Web.Mvc;

using EvacuationFacilityLib.Repositories;

namespace EvacuationFacilityWeb.Controllers
{
  public class HomeController : Controller
  {
    public ActionResult Index()
    {
      return View();
    }

    [HttpPost]
    public ActionResult Index(double latitude, double longitude, int distance)
    {
      ViewBag.EvacuationFacilities = EvacuationFacilityInfoRepository.Search(longitude, latitude, distance);

      return View("result");
    }
  }
}

ビューを実装

Index及びResultのビュー定義は以下の通りです。
実行時の緯度経度は navigator.geolocation.getCurrentPosition() で取得します。

// EvacuationFacilityWeb\Views\Home\Index.cshtml
<script type="text/javascript">
  if ("geolocation" in navigator) {
    navigator.geolocation.getCurrentPosition(function (position) {
      // POST用
      $('#latitude').val(position.coords.latitude);
      $('#longitude').val(position.coords.longitude);
      // 表示用
      $('#spanLatitude').html(position.coords.latitude);
      $('#spanLongitude').html(position.coords.longitude);

      $('#currentLocationGmapUrl').attr('href', 'https://www.google.co.jp/maps/place/' + position.coords.latitude + '+' + position.coords.longitude);
    },
    function (err) {
      alert(err.code + ': ' + err.message);
    });
  } else {
    alert('geolocation is not supported.');
}

</script>

<style>
.row {
  padding-top: 5px;
  padding-bottom: 5px;
}
</style>

@using (Html.BeginForm())
{
<div class="row">
  <div class="col-xs-2"></div>
  <div class="col-xs-8">
    近隣の避難施設を検索します。
  </div>
  <div class="col-xs-2"></div>
</div>
<div class="row">
  <div class="col-xs-5 text-right">
    現在地:
  </div>
  <div class="col-xs-7">
    緯度<span id="spanLatitude"></span> 経度:<span id="spanLongitude"></span>
    <br />
    <a id="currentLocationGmapUrl" target="_blank">地図を表示</a>
  </div>
</div>
<div class="row">
  <div class="col-xs-5 text-right">
    距離範囲:
  </div>
  <div class="col-xs-7">
      <select id="distance" name="distance">
        <option value="500">0.5 km 範囲内</option>
        <option value="1000">1 km 範囲内</option>
        <option value="3000">3 km 範囲内</option>
        <option value="5000">5 km 範囲内</option>
      </select>
  </div>
</div>
<div class="row">
  <div class="col-xs-5 text-right">

  </div>
  <div class="col-xs-7">
    <input type="hidden" id="latitude" name="latitude" />
    <input type="hidden" id="longitude" name="longitude" />
    <input type="submit" value="避難施設を検索" class="btn btn-default" />
  </div>
</div>
}
<hr />
「navigator.geolocation.getCurrentPosition()」で現在位置を取得するので、LANだとIPが降られた場所で遠くになるかも・・・

以下は resultビュー です。
検索結果はHomeControllerにより ViewBag.EvacuationFacilities にEvacuationFacilityInfo配列として格納されています。
それらのデータを一覧レンダリングします。 Google Mapsへのリンクはソースにあるように緯度・経度 をクエリーパラメータとすることで実装します。

// EvacuationFacilityWeb\Views\Home\result.cshtml
<h2>検索結果</h2>

<a href="./">戻る</a>

@if (this.ViewBag.EvacuationFacilities != null)
{
  <p>@this.ViewBag.EvacuationFacilities.Count 件</p>
  foreach (EvacuationFacilityLib.Models.EvacuationFacilityInfo facility in this.ViewBag.EvacuationFacilities)
  {
    <p>名称:@facility.Name</p>
    <p>住所:@facility.Address</p>
    <p>施設タイプ:@facility.FacilityType</p>
    <p><a target="_blank" href='@System.String.Format("https://www.google.co.jp/maps/place/{0}+{1}", facility.Latitude, facility.Longitude)'>地図を表示</a></p>
    <hr />
    
  }
}

まとめ

緯度・軽度からのクエリーというのは、なかなか色々なところで使えそうですね。
特にスマホアプリ系では利用用途が多そうに思います。スマホネイティブ(Xamarin Forms含め)なら位置情報もより正確に取得出来、マップを含めたアプリ連携がよりスムーズです。
さらに技術を進めると単純な位置関係だけでなく、道路を考慮した実移動距離となってきますが、Azureに限らずどんどん進化していくような気がしています。

ということで、今回、ソースの解説は結構 雑い かったと思いますが、一式をgithubにあげたので、そちらを見ていただければ幸いです。