2017.9.21
Livvon#9【ジオコーディング#3-緯度経度を用いた検索-】
今日はほうじ茶ラテです。マックは100円のコーヒーで居座れるので便利なんですが、まぁたまにはね。一回ぐらい飲んでみたかったし。あと、スタバだと背もたれがつきます(笑)
とはいえ15号鶴見店だとさらに椅子のランク上がるし、15号新子安店でも普通の椅子に座れるからなぁ・・・・・・笑
さて、今日の活動日記です。
今回のプロダクトに於いて、一番の山場だと予想されるのが、このジオコーディングを用いた結果表示、ですね。
まぁ、やりたいことは「画面内に存在する物件をピンで表示する」ってだけなんですけど、どう考えてもバグに手を焼かされるにおいしかしませんね。
最終的な到達点としては、javascriptを使って、Ajaxで動的に結果を取ってくる、ということなんですけど、そこまでプロトタイプで実装しなくてもいいかな
っていうか早く動くものを完成させたいので、その一歩手前として
中心座標を引数に取って結果を配列で返す静的メソッドを実装することを目標にします。
その配列を受け取ってGoogleMapsAPIに渡して正常に表示できれば、それを非同期処理に切り替えるだけでいいですからね。
ここでちと考えなくてはいけないのが
Geocodingクラスとの兼ね合いですね。
まぁ、別に継承とかする必要はないので、これから作るクラス内でインスタンスを生成する感じで良さそうですかね。
こういうのは設計段階でやっとくことなんだよな普通はな!?
まぁ、プログラミングセンスのなさに関してはもはや諦めがついたので、もういいことにします...僕には向いてない...笑
というか、しょせん個人開発でしかないので、作ったほうが早いってのが実情なんですよね・・・・・・。そもそも最初から作り直すつもりでプロトタイプ作ってるわけですしね。
さて、検索に使うクラスを仮に
GeoSearchクラスとしましょうか。
方針としては、コンストラクタに中心座標とズーム倍率をぶっこめばいいわけですね。
ただ、この中心座標、どうせならGeocordingインスタンスをぶっ込んでもいいかも知れませんね。
ちょっと密結合になりすぎる感もありますけど、住所やキーワードから位置情報を取得する、というのは全部Geocodingクラスでやっちゃえば?って思想であれば問題ないでしょ。
さて、ここで問題になるのが現状Geocodingクラス内に「キーワード=>住所」という処理の流れが存在しないことなので、まずはこれを作りましょうか。
と、思って作り始めてみたのはいいものの、よくよく考えてみるとgoogleMapの表示領域なんて固定化できないですね。そもそもこういう環境変数は状況に応じて変えられるように作っておくのがベストですね。
となると、キャンパスサイズの取得なんて明らかにコントローラーで処理できないであろうって感じなので素直にjavascriptで書かなくてはって感じですね。
つまり、作るべきメソッドはやはり
四角の座標を受け取って結果を生成するって感じのやつが良さげですかね。
google.maps.Mapクラスを直接渡してもいいんですけど、javascriptのクラスを渡していい感じになりそうな予感がまるでしないので諦めます(笑)(笑)
あと、GoogleMapsとMysqlとの連携をちまちま調べていると出てきたのが
geometry型というおもしろそうな型の存在ですね。
従来の、LATとLNGをそれぞれのカラムに登録するより、より高度な位置情報計算が可能になるみたいですね。せっかくなので作ってみましょう。
googlemapに描写するにあたり、
Fusion Tablesとかいうサービスが使えるみたいなのですが、まずはこれを使わないで実装してみましょうぞ。
やり方に関しては
ここなんかが分かりやすそうなのでこちらを採用。
ただ、どうやらMySQL(使ってるのはMariaDBだけど笑)の関数を使わざるを得ないということで、どうしても生のSQLを書かなくてはいけない、ということでSQLインジェクションのことを気にしなくてはいけませんね。まぁプレースホルダ使えばいいだけっちゃそーなんですけどね。
んで、結局Ajax使わざるを得ないって感じになるんですよね。マップ画面を一旦描写しないことにはサイズなんて分かりませんし。
ただ、Ajaxに関しては過去に書いたコードが再利用できるのでコピって来ましょう。具体的にはこの日記の「1日前を読み込む」ボタンですね。あれが非同期通信だから無限に読み込めるんですよね!知ってました!?(何)
自分の書いたコードは一番信用できるので最優先で利用します。(笑)(笑)
Ajax使ってデータを取得するとなると、どっか別の場所に置く予定の結果リストも動的に生成するハメになるのか・・・・・・まぁ、別にこれは更新とか考えなければそんなに難しくないし後々のために最初から実装するか・・・・・・。
んじゃ、以下ハマったところの覚書。
・Map.getBounds()がundefinedになる。
テキトーにググったところ、stackoverflowに解法があったのでそれを参照。
要するに、タイルのロードが終わってないのに呼び出すんじゃねぇよボケって感じですかね。
そのため、addListenerでハンドラを登録して、「境界が変わった時」のタイミングで関数を呼び出すようにすれば必然的に呼び出される、という思想ですね。
ただ、これだけだと弱くて、LatLngBoundsクラスのオブジェクト取得が完了していないうちに駆動させることになるので、さらにidleトリガでラップすることでうまくいきました。
<script type="text/javascript" src="http://maps.google.com/maps/api/js?key=<APIキー>"></script>
var map;
function initialize(lat, lng, zoom) {
//キャンバス準備
map = new google.maps.Map(document.getElementById("map_canvas"), {
zoom: zoom,
center: new google.maps.LatLng(lat, lng),
mapTypeId: google.maps.MapTypeId.ROADMAP
});
google.maps.event.addListenerOnce(map, 'idle', function() {
google.maps.event.addListener(map, 'bounds_changed', <呼び出す関数>);
});
}
これが正解です。initializeに中心座標の情報とズーム倍率をぶっ込むといい感じに初期化してくれます。
あ、このスクリプトを読み込む前に1行目のようにgooglemapのAPIを呼び出すのを忘れないようにしましょうね^^(googleなんてオブジェクトないんやけどって怒られました笑)
大体のサイトだとクロージャを使って定義してることが多いんですが、ネストが深くなってわけわかんなくなるので僕は別個に定義します・・・・・・笑
・GeomFromText系は「経度」「緯度」の順
なんも考えずにコーディングしたせいで、こんなトラップに引っかかってました。普通に緯度経度でデータ打ち込んでたんですが、経度が先に来るんですね。
例えば・・・・・・
//データベースに登録する
$newid = DB::table('users')->insertGetId([
'email' => $request["email"],
password' => bcrypt($request["password"]),
'lat' => $geocoding->get_lat(),
'lng' => $geocoding->get_lng(),
'geometry' => DB::raw('GeomFromText(\'POINT('.$geocoding->get_lng().' '.$geocoding->get_lat().')\')'),
....
登録時にこんな感じの処理をやってるんですけど、ここでPOINTとかいう関数を呼び出してるんですけど、見て分かる通りlng、つまり
引数は経度が先なんですね。
そんなんわかるかよ・・・・・・こんなくだらない仕様で平気で1時間近く溶かすからプログラミングはきらいなんだ・・・・・・(笑)
SELECT * FROM `users` WHERE MBRContains(
GeomFromText(
'LineString(140.58985251983643 35.884878121361396,138.88697166046143 34.989785155068965)'
)
,geometry
)
で、テキトーな範囲検索のSQL文を書くとこうなります。これで僕の自宅が引っかかります。大ヒントですよみなさん!!!←
ここでも経度を先に指定するのがミソですね。二点検索なので点の順番はどっちでもいいんですが、経度と緯度の間は半角スペースです。
これをLaravelに落とし込むと以下のようになります。あ、Eloquentはよくわかんないので使いません。笑
//DB検索実行
$str = $this->nelng." ".$this->nelat.",".$this->swlng." ".$this->swlat;//文字列生成
$this->result = DB::table('users')
->select('id','name_first','name_first_kana', 'name_last', 'nama_last_kana',
'age', 'sex','price','house_pr')//とりあえず(検索結果画面に表示させるものだけ)テキトーに取得。
->where('is_active', '=', 1)//ホストをアクティブにしている人のみ
->where('email_confirm',1)//メール認証が終わっていない人は、連絡不能扱いにしてしまう
//↓ほんとはプリペアドステートメント使いたいが何故か動作しないため諦め。is_numericでバリデートしているから大丈夫だと信じたい(笑)
->whereRaw("MBRContains(GeomFromText('LineString(".$str.")') , geometry)")
->get();
プリペアドステートメントを諦めるとか信じられないことしてますね(笑)
3時間ぐらい格闘したんですけど、無理っぽいので諦めました。まぁここには数値しかセットされないはずだしセキュリティホールは生まれないと信じたい・・・・・・。
めちゃくちゃ気持ち悪いんですけどね。セキュリティをおざなりにする開発者は一番嫌いなのですが、動かないものは仕方がありません・・・・・・そのうち誰かがなんとかしてくれることを信じるしかありません←
なんか原因は
PDO::ATTR_EMULATE_PREPARES => falseを渡すと死亡する、みたいな感じらしいのですが、これはつまりプリペアドステートメントができないことを意味するみたいですね。
ん???
まぁわからないものは放置しましょう。自力じゃ無理です笑
この結果をjsonで返すようなコントローラーを書けばいいわけですね。
ということで書きました。
public function search_ajax(Request $request){
$nelat=$request->nelat;
$nelng=$request->nelng;
$swlat=$request->swlat;
$swlng=$request->swlng;
if(empty($nelat) || empty($nelng) || empty($swlat) || empty($swlng)){return false;}
$geosearch =new GeoSearch($nelat,$nelng,$swlat,$swlng);
$geosearch->search();
$geosearch->getResult();
return Response::json($geosearch->getResult());
}
まんまですね。ちなみにおもしろいのがこのsearch_ajax、ルーティング上ではGETメソッドとして受け取ってるんですよね。
(内部的にはPOSTらしいですけど)
以前Ajax書いた時はPOSTで送信してたんですが、なんか今はGETじゃないと動かない感じになってるみたいなんですね。
function get_ajax() {
//キャンパスの四角を取得する
var latlngbounds = map.getBounds();
var sw = latlngbounds.getSouthWest();
var ne = latlngbounds.getNorthEast();
var nelat = ne.lat(); //北東の緯度
var nelng = ne.lng(); //北東の経度
var swlat = sw.lat(); //南西の緯度
var swlng = sw.lng(); //南西の経度
//Ajaxでデータを取ってくる。
console.log("{{route('search_ajax')}}/"+nelat+"/"+nelng+"/"+swlat+"/"+swlng);
$.ajax({
url: "{{route('search_ajax')}}/"+nelat+"/"+nelng+"/"+swlat+"/"+swlng,
type: 'get',
dataType: 'json',
timeout: 10000,
}).done(function (data) {
set_locations(data);
display_results(data);
}).fail(function (XMLHttpRequest, textStatus, errorThrown) {
console.log("error");
});
};
なんかソースコード公開大会みたいになってますけど、これがAjax部分の完成形ですね。書き方がちょっと昔と変わってるんですよね。
まぁjavascriptは普通に書けば閲覧可能なのでここで公開したところで害はないんですけどね笑 隠す方法ってどんなんがあるんですかね。phpとして外部読み込みとかして、直接アクセスを禁止するとか?できるの?笑
function set_locations(result) {
//結果をマップに描写する
var marker = [];
var data = [];
$.each(result, function (key, value) {
data.push(
{
'name': value.price,
'lat': value.lat,
'lng': value.lng
}
);
});
for (var i = 0; i < data.length; i++) {
markerLatLng = {lat: Number(data[i]['lat']),lng: Number(data[i]['lng'])};
marker[i] = new google.maps.Marker({
position: markerLatLng,
map: map
});
}
}
最後に、jsonオブジェクトを渡したらマップに描写してくれる関数。
そのまんまですね。言うことはありません。ただこのままスクロールすると
エラーを吐き出して落ちるので、原因を見つけ出してなんとか対策しなくてはって感じですかね。
まぁおそらく制限に引っかかったって感じなんでしょうけどね。今のところなんの制約もなくイベントを駆動させてるんですが、微小な移動は無視、もしくは一定時間(1秒とか?)のマージンを持たせるとかがいいですかね。
ついでだし実装しちゃいましょうか。せっかくなのでプロトタイプを使ってオブジェクト指向的に書きましょう。そっちのほうが管理しやすいし。
最終的にとりあえず動くようになったjavascriptのソースコードを一挙公開です。(長いです笑)
<script type="text/javascript" src="http://maps.google.com/maps/api/js?key=<APIキー>"></script>
<script>
var map;
var watcher;//読み込み間隔制限
var canvas;//キャンバス情報取得
//キャンバス情報記憶クラス
var Canvas = (function() {
var Canvas = function() {
if(!(this instanceof Canvas)) {
return new Canvas();
}
}
var p = Canvas.prototype;//プロトタイプ生成
//四角と中心の情報をセットする
p.setlatlng = function() {
var latlngbounds = map.getBounds();
var center = map.getCenter();
var sw = latlngbounds.getSouthWest();
var ne = latlngbounds.getNorthEast();
this.nelat = ne.lat(); //北東の緯度
this.nelng = ne.lng(); //北東の経度
this.swlat = sw.lat(); //南西の緯度
this.swlng = sw.lng(); //南西の経度
this.celat = center.lat();//中心の緯度
this.celng = center.lng();//中心の緯度
this.zoom = map.getZoom();//ズーム倍率
}
return Canvas;
})();
//読み込み制限・監視クラス
var Watcher = (function() {
var Watcher = function() {
if(!(this instanceof Watcher)) {
return new Watcher();
}
this.reload();//状態を保存する
}
var p = Watcher.prototype;//プロトタイプ生成
// キャンバス情報を登録する
p.reload = function() {
this.timestamp = new Date().getTime();
canvas.setlatlng();
this.nelat = canvas.nelat; //北東の緯度
this.nelng = canvas.nelng; //北東の経度
this.swlat = canvas.swlat; //南西の緯度
this.swlng = canvas.swlng; //南西の経度
this.celat = canvas.celat; //中心
this.celng = canvas.celng;
this.zoom = canvas.zoom; //南西の経度
}
// 再読込可能か判定する
p.isCanReload = function() {
var nowtime = new Date().getTime();//現在のタイムスタンプ取得
if(nowtime - this.timestamp <1000){return false;}//1秒経過していなければ拒否
//ズーム倍率が変わっていたら再読込
if(canvas.zoom != this.zoom){return true;}
canvas.setlatlng();
//とりあえず簡単に、「中心座標が」前回取得した範囲外に移動した場合読み込む、ということにしておく
//if(canvas.celng < this.swlng || this.nelng < canvas.celng){return true;}
//if(canvas.celat < this.swlat || this.nelat < canvas.celat){return true;}
//1/8ぶん画面が移動したら再読込
var diflng = Math.abs(this.nelng-this.swlng);
var diflat = Math.abs(this.nelat-this.swlat);
var difcelat = Math.abs(this.celat - canvas.celat);
var difcelng = Math.abs(this.celng - canvas.celng);
if(diflat/8.0 < difcelat){return true;}
if(diflng/8.0 < difcelng){return true;}
return false;
}
return Watcher;
})();
function try_ajax(){
console.log("in bounds_changed");
if(watcher == null){
canvas = new Canvas();
watcher = new Watcher();
}else{
console.log("if watcher !null");
if(watcher.isCanReload() === false){return false;}
}
console.log("try get ajax!");
get_ajax();//データを取得する
watcher.reload();//監視基準点を更新する
}
function initialize(lat, lng, zoom) {
//キャンバス準備
map = new google.maps.Map(document.getElementById("map_canvas"), {
zoom: zoom,
center: new google.maps.LatLng(lat, lng),
mapTypeId: google.maps.MapTypeId.ROADMAP
});
google.maps.event.addListenerOnce(map, 'idle', function() {
try_ajax();//なんか読み込まれないからここでも読み込む
google.maps.event.addListener(map, 'bounds_changed', function(){
try_ajax();//クロージャにしないと落ちるので・・・・・・。
});
});
}
function set_locations(result) {
//結果をマップに描写する
var marker = [];
var data = [];
$.each(result, function (key, value) {
data.push(
{
'name': value.price,
'lat': value.lat,
'lng': value.lng
}
);
});
for (var i = 0; i < data.length; i++) {
markerLatLng = {lat: Number(data[i]['lat']),lng: Number(data[i]['lng'])};
marker[i] = new google.maps.Marker({
position: markerLatLng,
map: map
});
}
}
function display_results(result) {
//結果をHTMLに出力する
}
function get_ajax() {
//キャンパスの四角を取得する
canvas.setlatlng();//現在のキャンパス情報をインスタンスにセット
//Ajaxでデータを取ってくる。
console.log("{{route('search_ajax')}}/"+canvas.nelat+"/"+canvas.nelng+"/"+canvas.swlat+"/"+canvas.swlng);
return $.ajax({
url: "{{route('search_ajax')}}/"+canvas.nelat+"/"+canvas.nelng+"/"+canvas.swlat+"/"+canvas.swlng,
type: 'get',
dataType: 'json',
timeout: 10000,
}).done(function (data) {
/*返り値を渡すのがめんどくさそうなので、ここで全部処理してしまうことにした*/
set_locations(data);
display_results(data);
}).fail(function (XMLHttpRequest, textStatus, errorThrown) {
console.log("error");
});
};
</script>
※これ、Watcherクラスのメンバ変数作ったの失敗だったなぁ・・・・・・。Canvasの方に時間情報持たせて、Canvasクラスのインスタンス生成して、保存しておいたインスタンスと比べた方がコード量減るしスマートだった。。。ね、センスないでしょ(笑)
なんとなくでクラスに切り分けたりしています。
まぁ、がんばったほうだと思います。(笑)コメントから努力の跡が垣間見えますが、僕にはこれ以上どうしようもできないです笑
今日中に結果表示までいけるかなーとか思ったんですが、さすがに予定に追いつきそうなので今日から筋トレとジョギング再開したいので諦めます。明日やろう。明日やろうはばかやろうとか言われそーだけど笑
あ、これ今日の結果発表。(笑)
ほんとは説明文とか表示させたかったんですが、プロトタイプで実装する必要はない気がしてきたのでやめました。笑
めっちゃぼそっとつぶやくけど、日に日に他人に対する興味が薄れていっててつらい(笑)
岡崎体育の
感情のピクセルなんて曲を今日はずっと聞いてたんですけど、しょせん僕はワニさんにしかなれないんだなーと。みんなと生き物としてのカテゴライズが違うのだ、きっと・・・・・・
思想が一ヶ月ぐらい前からずっと変わってないですね。(笑)
なんていうか、僕はこの世界そのものに興味を失ってきてるんだなーと。
とりあえず夢だけパパっと叶えておさらばしたいところであるよ。きっと僕が望むものは一生手に入らない気がしているから。(笑)
ん、何を欲しがっているのかって?
僕のことを好きになってくれる、愛する人が欲しいのだ。
僕のことを認めてくれて、一緒にいてくれる素敵な人が欲しいのだ。
でも、そんな人、空想上の生き物でしか、ないんでしょ?