WebDesign Dackel

【GoogleMaps API】住所リストを現在地から近い順にソート

【GoogleMaps API】住所リストを現在地から近い順にソート

Hatena0
Google+0
Pocket0
Feedly0

Google Maps APIを使うと色々な事が出来るのですが、今回はある住所のリストを現在地から近い順にソートする、というものを実装してみたいと思います。

動作サンプル

実際に動作するサンプルを用意しました。
ページを開くと、位置情報を取得してもよいかと聞かれるので許可して下さい。
HTML5のGeolocationAPIを使っているため、スマホや現在地の設定が出来るデスクトップPCでご確認下さい。

サンプル

住所のリスト

中身はなんでも良かったのですが、とりあえず都内にある有名ラーメン店をいくつかピックアップしてみました。
データは後で使いやすいように配列に入れておきます。

var dataList = [
    {
        "name": "麺処くるり市ヶ谷店",
        "address": "東京都新宿区市谷田町3-2"
    },
    {
        "name": "SOBAHOUSE 不如帰",
        "address": "東京都渋谷区幡ヶ谷2-47-12"
    },
    {
        "name": "狼煙屋",
        "address": "東京都東大和市清水6-1257-17"
    },
    {
        "name": "中華そば 春木屋",
        "address": "東京島豊島区南池袋2-42-8"
    },
    {
        "name": "五ノ神製作所",
        "address": "東京都渋谷区千駄ヶ谷5-33-16"
    },
    {
        "name": "一燈",
        "address": "東京都葛飾区東新小岩1-4-17"
    },
    {
        "name": "道",
        "address": "東京都葛飾区亀有5-28-17"
    },
    {
        "name": "こうかいぼう",
        "address": "東京都江東区深川2-13-10-101"
    },
    {
        "name": "田中商店",
        "address": "東京都足立区一ツ家2-14-6"
    },
    {
        "name": "Japanese Soba Noodle 蔦",
        "address": "東京都豊島区巣鴨1-14-1"
    },
    {
        "name": "中華ソバ みなみ",
        "address": "東京都板橋区前野町4-58-10"
    }
];

HTMLの用意

データを出力するHTMLを用意します。現在地の取得やAPIの使用はsample.jsで行っていきます。
非同期処理を簡単に行う為にサンプルではjQueryを使用していますが、GoogleMapsAPIや現在地の取得自体にはjQueryは必要ないのでここらへんはお好みでどうぞ。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>GoogleMapsAPI</title>
</head>
<body>

    <table>
        <thead>
            <tr>
                <th>順番</th>
                <th>店名</th>
                <th>現在地からの距離</th>
            </tr>
        </thead>
        <tbody id="data-list"></tbody>
    </table>

    <script type="text/javascript" src="//maps.google.com/maps/api/js?sensor=false&region=JP"></script>
    <script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
    <script type="text/javascript" src="sample.js"></script>
</body>
</html>

アウトライン

少しだけ長くなりそうなので、細かい実装の前に大枠となる部分を書いていきます。
流れとしてはこんな感じになりそうです。

  1. データのリストから緯度経度を取得(非同期)
  2. 現在位置の取得(非同期)
  3. DOMContentLoadedイベント(非同期)
  4. 全ての非同期処理が終わったら
  5. それぞれの住所と現在地の距離を算出
  6. 算出した距離の値が小さい(近い)順にソート
  7. 結果をテーブルに出力
    var dataList = [
        // 省略
    ];

    function dfdGeocode(){
        // データリストから緯度経度を取得
    }

    function dfdCurrentPosition(){
        // 現在位置の取得
    }

    // DOM Content Loaded
    function dfdDocumentReady(){
        var dfd = $.Deferred();
        $(function(){
            dfd.resolve();
        });
        return dfd.promise();
    }

    // 非同期処理を並列化
    $.when(
        dfdCurrentPosition(),
        dfdGeocode(),
        dfdDocumentReady()
    ).done(function(position){
        // 全ての非同期処理が終わったら

    }).fail(function(){
        alert("お使いの端末の位置情報サービスが無効になっているか対応していないため、エラーが発生しました");
        console.log("error", arguments);
    });

簡略化のために細かいエラーは設定していませんので、必要に応じて追加していく必要があるかもしれません。
ちなみに非同期処理にjQuery.Deferredを使っているのですが、下記の記事が凄く参考になりました!

結局jQuery.Deferredの何が嬉しいのか分からない、という人向けの小話

さて、大枠を用意したところでそれぞれの実装を行ないます。

現在地の取得

geolocationが使用可能かチェックを行って、使用できるようであればgetCurrentPosition()を使って現在地の取得を行ないます。
第一引数には値が正常に取得できた場合のコールバック関数、第二引数にはエラー時、第三引数には取得に使用するオプションを指定します。
成功時のコールバックの第一引数には、現在地を持ったオブジェクトが入ってきます。あとでこの値を元に距離の算出を行ないます。
値はresolve()に渡しておくことにしました。

// 現在位置の取得
function dfdCurrentPosition(){
    var dfd = $.Deferred();

    // Geolocationが使用可能かチェック
    if( !window.navigator.geolocation ) dfd.reject();

    // 現在地の取得
    window.navigator.geolocation.getCurrentPosition(
        // Success
        function(position){
            dfd.resolve(position);
        },
        // Error
        function(error){
            dfd.reject();
        },
        // Options
        {
            enableHighAccuracy:true, //位置情報の精度を高く
            timeout: 10000, //10秒でタイムアウト
            maximumAge: 600000 //10分間有効
        }
    );

    return dfd.promise();
}

参考サイト
Geolocation の利用

住所から緯度経度を取得

GoogleMapsAPIGeocoderクラスのインスタンスを生成、geocode()を使って住所から緯度経度の変換を行ないます。
第一引数に渡しているaddressには、ジオコーディングする住所を指定します。サンプルでは「東京都新宿区市谷田町3-2」の様な値が入ってきます。

// データリストの緯度経度を取得
function dfdGeocode(){
    var dfd = $.Deferred();

    // Geocoderのインスタンスを生成
    var geocoder = new google.maps.Geocoder();

    // カウンター
    var cnt = 0;

    // データ分緯度経度の取得
    $.each(dataList, function(i, data){
        geocoder.geocode({
            address: data.address
        }, function(d, status){
            data.lat = d[0].geometry.location.lat(); //緯度
            data.lng = d[0].geometry.location.lng(); //経度
            cnt++;
            if( cnt === dataList.length ){
                dfd.resolve();
            }
        });
    });

    return dfd.promise();
}

参考サイト
Google Maps JavaScript API v3 ジオコーディング サービス

現在地からの距離を計算

現在地の取得と住所の緯度経度が分かったら、それぞれの(ラーメン屋の)住所と現在地の距離を計算します。
done()の第一引数に入っているpositionは、現在地の取得時にresolve()に渡したものがそのまま入ってきます。

$.when(
    // 省略
)
.done(function(position){

    // 現在地
    var coords = position.coords;

    // 距離の割り出しを行ない、各データにdistance属性を設定
    $.each(dataList, function(i, data){
        data.distance = getDistance(data.lat, data.lng, coords.latitude, coords.longitude, 0) / 1000; //kmで算出
    });

})
/**
 * 2点間の緯度経度から距離を取得
 * 測地線航海算法を使用して距離を算出する。
 * @see http://hamasyou.com/blog/2010/09/07/post-2/
 * @param float 緯度1
 * @param float 経度2
 * @param float 緯度2
 * @param float 経度2
 * @param 小数点以下の桁数(べき乗で算出精度を指定)
 */
function getDistance(lat1, lng1, lat2, lng2, precision){
  var distance = 0;
  if( ( Math.abs(lat1 - lat2) < 0.00001 ) && ( Math.abs(lng1 - lng2) < 0.00001 ) ) {
    distance = 0;
  }else{
    lat1 = lat1 * Math.PI / 180;
    lng1 = lng1 * Math.PI / 180;
    lat2 = lat2 * Math.PI / 180;
    lng2 = lng2 * Math.PI / 180;

    var A = 6378140;
    var B = 6356755;
    var F = ( A - B ) / A;

    var P1 = Math.atan( ( B / A ) * Math.tan(lat1) );
    var P2 = Math.atan( ( B / A ) * Math.tan(lat2) );

    var X = Math.acos( Math.sin(P1) * Math.sin(P2) + Math.cos(P1) * Math.cos(P2) * Math.cos(lng1 - lng2) );
    var L = ( F / 8 ) * ( ( Math.sin(X) - X ) * Math.pow( (Math.sin(P1) + Math.sin(P2) ), 2) / Math.pow( Math.cos(X / 2), 2 ) - ( Math.sin(X) - X ) * Math.pow( Math.sin(P1) - Math.sin(P2), 2 ) / Math.pow( Math.sin(X), 2) );

    distance = A * ( X + L );
    var decimal_no = Math.pow(10, precision);
    distance = Math.round(decimal_no * distance / 1) / decimal_no;
  }
  return distance;
}

参考サイト
緯度経度の距離算出には参考サイトのものをほぼそのまま使用させて頂きました。ありがとうございます!
緯度・経度から二点間の距離と方向を計算する – それはBooks

距離の近い順にソート

ここまでくれば後は簡単で、距離の値を元にしてソートしていきます。

// 現在地からの距離が小さい順にソート
dataList.sort(function(a, b){
    return (a.distance < b.distance) ? -1 : 1;
});

HTMLに出力

今回はテーブルとして出力を行っていきます。今回の要件とはあまり関係有りませんが、店名にGoogleMapsへのリンクを付けてみました。

// データを出力
var html =  "";

$.each(dataList, function(i, data){
    html += '<tr>';
        html += '<td>'+(i+1)+'</td>';
        html += '<td><a href="https://maps.google.co.jp/maps?q='+data.lat+','+data.lng+'&z=17&iwloc=A" target="_blank">';
            html += data.name;
        html += '</a></td>';
        html += '<td>'+data.distance+'km</td>';
    html += '</tr>';
});

$("#data-list").append(html);

全体のコード

ここまでバラバラと書いてしまいましたので全体のコードを記載しておきたいと思います。

;(function($, window, undefined){


    // データリスト、東京都のラーメン&つけ麺
    // 緯度経度取得後に、各データ毎にlat(緯度),lng(経度)が入ります。
    var dataList = [
        {
            "name": "麺処くるり市ヶ谷店",
            "address": "東京都新宿区市谷田町3-2"
        },
        {
            "name": "SOBAHOUSE 不如帰",
            "address": "東京都渋谷区幡ヶ谷2-47-12"
        },
        {
            "name": "狼煙屋",
            "address": "東京都東大和市清水6-1257-17"
        },
        {
            "name": "中華そば 春木屋",
            "address": "東京島豊島区南池袋2-42-8"
        },
        {
            "name": "五ノ神製作所",
            "address": "東京都渋谷区千駄ヶ谷5-33-16"
        },
        {
            "name": "一燈",
            "address": "東京都葛飾区東新小岩1-4-17"
        },
        {
            "name": "道",
            "address": "東京都葛飾区亀有5-28-17"
        },
        {
            "name": "こうかいぼう",
            "address": "東京都江東区深川2-13-10-101"
        },
        {
            "name": "田中商店",
            "address": "東京都足立区一ツ家2-14-6"
        },
        {
            "name": "Japanese Soba Noodle 蔦",
            "address": "東京都豊島区巣鴨1-14-1"
        },
        {
            "name": "中華ソバ みなみ",
            "address": "東京都板橋区前野町4-58-10"
        }
    ];


    // データリストの緯度経度を取得
    function dfdGeocode(){
        var dfd = $.Deferred();

        // Geocoderのインスタンスを生成
        var geocoder = new google.maps.Geocoder();

        // カウンター
        var cnt = 0;

        // データ分緯度経度の取得
        $.each(dataList, function(i, data){
            geocoder.geocode({
                address: data.address
            }, function(d, status){
                data.lat = d[0].geometry.location.lat(); //緯度
                data.lng = d[0].geometry.location.lng(); //経度
                cnt++;
                if( cnt === dataList.length ){
                    dfd.resolve();
                }
            });
        });

        return dfd.promise();
    }


    // 現在位置の取得
    function dfdCurrentPosition(){
        var dfd = $.Deferred();

        // Geolocationが使用可能かチェック
        if( !window.navigator.geolocation ) dfd.reject();

        // 現在地の取得
        window.navigator.geolocation.getCurrentPosition(
            // Success
            function(position){
                dfd.resolve(position);
            },
            // Error
            function(error){
                dfd.reject();
            },
            // Options
            {
                enableHighAccuracy:true, //位置情報の精度を高く
                timeout: 10000, //10秒でタイムアウト
                maximumAge: 600000 //10分間有効
            }
        );

        return dfd.promise();
    }


    // DOM Content Loaded
    function dfdDocumentReady(){
        var dfd = $.Deferred();
        $(function(){
            dfd.resolve($(document));
        });
        return dfd.promise();
    }


    // データが揃った段階でソートを開始
    $.when(
        dfdCurrentPosition(),
        dfdGeocode(),
        dfdDocumentReady()
    )
    .done(function(position){

        // 現在地
        var coords = position.coords;

        // 距離の割り出しを行ない、各データにdistance属性を設定
        $.each(dataList, function(i, data){
            data.distance = getDistance(data.lat, data.lng, coords.latitude, coords.longitude, 0) / 1000; //kmで算出
        });

        // 現在地からの距離が小さい順にソート
        dataList.sort(function(a, b){
            return (a.distance < b.distance) ? -1 : 1;
        });

        // データを出力
        var html =  "";

        $.each(dataList, function(i, data){
            html += '<tr>';
                html += '<td>'+(i+1)+'</td>';
                html += '<td><a href="https://maps.google.co.jp/maps?q='+data.lat+','+data.lng+'&z=17&iwloc=A" target="_blank">';
                    html += data.name;
                html += '</a></td>';
                html += '<td>'+data.distance+'km</td>';
            html += '</tr>';
        });

        $("#data-list").append(html);

    })
    .fail(function(){
        alert("お使いの端末の位置情報サービスが無効になっているか対応していないため、エラーが発生しました");
        console.log("error", arguments);
    });


    /**
     * 2点間の緯度経度から距離を取得
     * 測地線航海算法を使用して距離を算出する。
     * @see http://hamasyou.com/blog/2010/09/07/post-2/
     * @param float 緯度1
     * @param float 経度2
     * @param float 緯度2
     * @param float 経度2
     * @param 小数点以下の桁数(べき乗で算出精度を指定)
     */
    function getDistance(lat1, lng1, lat2, lng2, precision){
      var distance = 0;
      if( ( Math.abs(lat1 - lat2) < 0.00001 ) && ( Math.abs(lng1 - lng2) < 0.00001 ) ) {
        distance = 0;
      }else{
        lat1 = lat1 * Math.PI / 180;
        lng1 = lng1 * Math.PI / 180;
        lat2 = lat2 * Math.PI / 180;
        lng2 = lng2 * Math.PI / 180;

        var A = 6378140;
        var B = 6356755;
        var F = ( A - B ) / A;

        var P1 = Math.atan( ( B / A ) * Math.tan(lat1) );
        var P2 = Math.atan( ( B / A ) * Math.tan(lat2) );

        var X = Math.acos( Math.sin(P1) * Math.sin(P2) + Math.cos(P1) * Math.cos(P2) * Math.cos(lng1 - lng2) );
        var L = ( F / 8 ) * ( ( Math.sin(X) - X ) * Math.pow( (Math.sin(P1) + Math.sin(P2) ), 2) / Math.pow( Math.cos(X / 2), 2 ) - ( Math.sin(X) - X ) * Math.pow( Math.sin(P1) - Math.sin(P2), 2 ) / Math.pow( Math.sin(X), 2) );

        distance = A * ( X + L );
        var decimal_no = Math.pow(10, precision);
        distance = Math.round(decimal_no * distance / 1) / decimal_no;
      }
      return distance;
    }


}(jQuery, window));

まとめ

説明が不足している箇所がありますが、それぞれ途中であげた参考サイトを見て頂けたら問題無いかと思います。

今回JSで完結するように住所リストからの緯度経度取得を行っていますが、実際はページリクエストの度に取得するのは無駄が多いので、DBに値を持たせておく、JSONやCSVなどのファイルに保存しておくなどの対策が必要かなと思います。
また、現在地の取得に少し時間がかかるので、値を取得している最中はローディングを表示するなどの配慮もあると良いかと思います。

こういったAPIを使うことで、難しそうなものもさくっと作れてしまいます。
便利な技術には常にアンテナをはって上手く使うことで、よりユーザに優しい、使い勝手の良いサービスを開発していけたらと思います。

余談

実家が近いということもあり今週末、住所リストに挙げた亀有にある「つけ麺 道」に行ってきました。
このくそ寒いなか2時間半並びました。しかし、その時間並ぶだけの満足度が得られる名店でした!
皆さんも是非機会があったら並んでみて下さい。笑

道 – 亀有/つけ麺 [食べログ]

Hatena0
Google+0
Pocket0
Feedly0

Recent Posts

Comments

  • kakasi

    Apr 19, 2015

    初心者です。
    住所リストがを増やすにはどうすれば良いのでしょうか。

    返信

    • dackel

      Apr 21, 2015

      kakasi さん
      はじめまして、コメントありがとうございます。

      基本的には、ソース上部にあるdataList(配列)へオブジェクトを追加するようになっているのですが、何かエラーなどが出て動いていないでしょうか??
      それとも、配列やオブジェクトの追加方法について不明な点があるでしょうか?

  • kakasi

    Apr 22, 2015

    お忙しいところ有り難うございます。
    多分 {“name”:”■”,”address”:”■”},これを追加すれば良いと思ったのですが、追加すると表示しません、どこが悪いのでしょうか?
    http://www.kakasi923.com/public/s-guide2.html
    また、address 部分は住所より座標の方が簡単だったので座標にしています。

    返信

    • dackel

      Apr 23, 2015

      geocoder.geocode()の2つめの引数にある関数で受け取っているstatusをコンソールなどで出力してみてもらってもよいでしょうか?
      恐らくOVER_QUERY_LIMITというエラーが出ているかと思います。正しく取得できた場合はOKという文字列が返ってきます。
      これはGoogleMapsAPIに短時間の内に多くのリクエストを投げた際に出るエラーです。(記事の数がギリギリだったみたいです…)

      これを回避するためにはデータを取得するタイミングを調整する必要がありそうです。
      dfdGeocode()を下記の様に変更することで一応実現出来ました…

      // データリストの緯度経度を取得
      function dfdGeocode(){
        var dfd = $.Deferred();
      
        // Geocoderのインスタンスを生成
        var geocoder = new google.maps.Geocoder();
      
        // データ分緯度経度の取得
        // 5件毎に4秒のインターバルを置いて取得しています
        var i,
            data,
            last = 0,
            cnt = 0,
            requestInterval = 4000,
            requestLimit = 5;
      
        setTimeout(function loop(){
          for( i = last; i < dataList.length; i++ ){
            data = dataList[i];
      
            if( i > last + requestLimit ){
              last = i;
              setTimeout(loop, requestInterval);
              break;
      
            }else{
              (function(_data){
                geocoder.geocode({
                  address: _data.address
                }, function(d, status){
                  _data.lat = d[0].geometry.location.lat(); //緯度
                  _data.lng = d[0].geometry.location.lng(); //経度
                  cnt++;
                  if( cnt === dataList.length ){
                    dfd.resolve();
                  }
                });
              }(data));
            }
          }
        }, 0);
      
        return dfd.promise();
      }
      

      サンプル2

      件数によって、requestIntervalrequestLimitの数値を調整する必要があります。
      ただ、この方法だととても時間がかかってしまうのであまり現実的では無いかなぁ〜なんて思います。

  • kakasi

    Apr 23, 2015

    早速の回答ありがとうございます。
    まだ半分くらいしか理解できませんが、これから勉強させていただきます。
    csvの大量のデータ(2~300件)を読み込み、表示するのは現在地周辺の10件程度、なぁ~んて事ができたら、利用範囲は無限に広がるのではと考えています。

    返信

  • suwaki

    Nov 16, 2015

    はじめまして。今サンプルを見ながらやってるのですがボタンを押した時に起動したいのですが、3回に2回くらいTypeError:d is nullになってしまいます。

    全体をfunctionで括り、ボタンで実行みたいな感じです。
    原因が分からずご教授いただけますか?

    返信

    • dackel

      dackel

      Nov 17, 2015

      suwaki さん
      はじめまして。コメントありがとうございます。

      全てにエラーが出るのでは無く、正常に動くこともあるのですね…。

      もしかすると、dfdGeocode()内でのエラーではないでしょうか?

      geocoder.geocode()で渡ってくる、変数dstatusの中身を確認してみてください。
      GoogleAPI通信エラーも考えられます。

      (詳しい状態がわからないため、全然違う箇所を指してしまっているかもしれません…)

  • suwaki

    Nov 17, 2015

    コメントありがとうございます!
    おっしゃる通りdfdGeocode()内でのエラーです。

    やってることはxmlを読み込んで、配列を作ってそれを上記functionに渡しております。
    表示自体は5件なのですが、配列には現在30ほど住所が入っています。

    GPS許可前にdfdGeocode()は走りますよね?
    変数dはうまく行かないときは途中で止まってるみたいです。
    statusはOKがでてます。コメントにあるように配列が多すぎなのでしょうか??

    返信

  • suwaki

    Nov 18, 2015

    自己解決しました。やはり一度に参照する数が多すぎたせいでした。
    めんどくさいですが、座標は手動にすることで解決いたしました。
    ありがとうございました!

    返信

    • dackel

      dackel

      Nov 18, 2015

      suwaki さん
      返信遅くなってしまってしまいました。。

      お力になれなくて申し訳ないですが、解決出来たようでよかったです!

  • toku

    Jan 28, 2016

    お世話になります。javaが初心者のままで色んな文献を読みながら勉強しておりますが、初心者すぎて初歩的な質問だと思います。

    この機能相当使いこなしたいと思いました。が、読み込みするのに10件くらいだと良いのですが、15件くらいになると結構待たなければいけません。

    どうしてもこの機能を活用させて頂きたいのですが、コメントにもありましたが、100件くらいのcsvなどのデータリストを元に、近いお店10件程度を表示させる事は可能でしょうか?また、その場合、表示されるまでやはり時間がかかりますか?

    そしてどう設定してよいのかご指導頂ければ本当に嬉しいのですが><

    返信

    • dackel

      dackel

      Feb 01, 2016

      toku さん
      はじめまして。コメントありがとうございます。
      返信遅くなってしまいすみません。。

      表示までの時間が長くなる原因は、やはりGoogleのAPIを介した時なのでCSVのデータを用意されるようでしたら、その中に緯度経度を予め用意しておくのが現実的かなと思います。
      dfdGeocode関数で設定するlatlng元からデータとして持たせておくイメージです。

メールアドレスが公開されることはありません。お気軽にコメントどうぞ。