まず位置情報とは、人や建物などの場所のデータのことです。
点、線、領域(ポリゴン)であったりします。
これをシステムで扱いやすくするためにどのように扱うか、といったことを書いてみよようと思います。
緯度・経度
最も馴染みある指標で、精度も小数点の桁数を調整すれば無限に調整できます。
たとえば、渋谷駅の緯度経度は、 緯度:35.658034 経度: 139.701636
です。
他のシステムと連携したり、GPSから取得した際の最初はこの形が多いと思います。
たとえば、「ある地点から半径100メートル以内に近づいた人にプッシュ通知を出す」みたいな要件の場合は、このままの形で持っておき、アプリ内で定期的に位置情報を計算→距離算出などが出来ると思います。
距離算出などはこんな感じで出来ると思います。
let shibuya: CLLocation = CLLocation(latitude: 35.65803, longitude: 139.701636)
let shinjuku: CLLocation = CLLocation(latitude: 35.689592, longitude:139.700413)
shibuya.distance(from:shinjuku)
> CLLocationDistance = 3503.647464124931 // 3503メートル
ただ、この緯度経度をデータベースに貯めておいて、この地点から半径100メートル以内に近づいた人を探す、とかをしようと思うと相当扱いづらいです。
このようなケースは、まずPostGISを検討してみましょう。
PostGIS
PostgreSQLの位置情報データを扱うための拡張
位置情報をGeometry型という型でそのまま保存できます。
Geometry型では点(POINT)、線(LINESTRING)、領域(POLYGON)を扱うことが出来、距離や、領域に入っているかの計算ができます。
先程の距離の計算であれば、こんな感じで出来ます。
select *
from user_geometory_log
where
ST_Distance(point, ST_GeomFromText('POINT(139.701636 35.65803)', 4326 , false) <= 100;
細かいところはドキュメントをどうぞ
PostgreSQL以外にも、最近Athenaでサポート開始したりしています。
ジオハッシュ
一応PostGISでも大体のことは出来ますが、位置情報はとにかく量が大きくなりやすいので、RedShiftとかHadoopとか何らかのデータ解析基盤に入れたくなります。
こういったときは、PostGISは基本的に使えません。
あと、大量のデータを解析すると、よほど札束を積まない限り、遅いです。
そういったとき、精度は落ちますが、ジオハッシュに変換して保存しておくとよいです。
ジオハッシュについて
地球上の全座標を、緯度経度をもとにして分割したものだそうです。
これも桁数を上げればひたすら精度が上がっていくのと、ジオハッシュの領域は順番に横に並んでいるので、近い場所はジオハッシュの文字列も近くなります。
たとえば8桁のジオハッシュで、渋谷駅はxn76fgre
、新宿駅はxn774cnd
になります。
たとえば、先程のxn76fgre
を地図上に表示するとこんな感じです。
緑がxn76fgrd
、青がxn76fgrf
です。
青が7桁、赤が8桁です。
変換方法
NodeJSで緯度経度とジオハッシュを変換するライブラリはいくつかありますが、たとえばngeohashを使った場合、このように変換できます。
const geohash = require('ngeohash');
geohash.encode(35.65803, 139.701636, 8)
> 'xn76fgre'
const geohash = require('ngeohash');
geohash.decode_bbox('xn76fgre')
> [ 35.658016204833984,
> 139.7014617919922,
> 35.65818786621094,
> 139.7018051147461 ]
精度について
ジオハッシュは桁数を上げれば精度があがります。その時々で必要な精度を考えて設計しましょう。
場所によってサイズは微妙に変わってくるようですが、目安としてはこのくらいです。
桁数 | 南北の距離 | 東西の距離 |
---|---|---|
1 | 4,989,600.00m | 4,050,000.00m |
2 | 623,700.00m | 1,012,500.00m |
3 | 155,925.00m | 126,562.50m |
4 | 19,490.62m | 31,640.62m |
5 | 4,872.66m | 3,955.08m |
6 | 609.08m | 988.77m |
7 | 152.27m | 123.60m |
8 | 19.03m | 30.90m |
9 | 4.76m | 3.86m |
10 | 0.59m | 0.97m |
渋谷駅から100メートル以内の人を抽出するには
最初の目的に戻って、渋谷駅から100メートル以内にいた人を抽出するためには、こんな感じでさきにジオハッシュを準備しておく必要があります。
- 渋谷駅から100メートル以内のジオハッシュを全て抽出する
- クエリで抽出
渋谷駅から100メートル以内のジオハッシュを全て抽出する
これは結構難しいですが、NodeJSの場合、geolibを使って作成します。
他にもいろんなルゴリズムはありますが、とりあえずこんな感じで作りました。
- 渋谷駅から100メートルの円を覆う四角形をつくる
- 1のジオハッシュを全て取得(ここで、一時的にジオハッシュを32進数に変換して隣り合うジオハッシュを全て出しています)
- 2のジオハッシュを、ひとつひとつ、geolibで渋谷駅から中心点が100メートル以内かチェック
const ngeohash = require('ngeohash');
const geolib = require('geolib');
// ジオハッシュで使われる文字一覧
const geohashChars = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'];
/**
* 対象エリアを包括する四角形エリアのgeohashのリストを取得する
* @param {Object} latLng
* @param {Number} radius
*/
function getArea(center, radius) {
function makeGeoHashNum(bearing) {
var p = geolib.computeDestinationPoint(center, radius * Math.sqrt(2), bearing);
var geohash = ngeohash.encode(p.latitude, p.longitude, 8);
var n = geohashToNum(geohash);
return n;
}
var shape = [
makeGeoHashNum(-45),
makeGeoHashNum(45),
makeGeoHashNum(-135),
makeGeoHashNum(135)
];
var min = Math.min.apply(null, shape);
var max = Math.max.apply(null, shape);
var geohashList = [];
for(var i = min; i <= max; i++) {
var geohash = numToGeohash(i);
var point = ngeohash.decode(geohash);
geohashList.push({geohash: geohash, point: point});
}
return geohashList;
}
/**
* ジオハッシュを計算用の数値(32進数)に変換
* @param {String} geohash
*/
function geohashToNum(geohash) {
var result = 0;
var base = 1;
for (i = geohash.length - 1;i >= 0; i--) {
result = result + geohashChars.indexOf(geohash[i]) * base;
base = base * 32;
}
return result;
}
/**
* 計算用の数値(32進数)からgeohashに戻す
* @param {Number} num
*/
function numToGeohash(num) {
var result = '';
while(num > 0) {
result = geohashChars[num % 32] + result;
num = Math.floor(num / 32);
}
return result;
}
// 距離
var radius = 100;
// 中心点(渋谷駅)
var center = { latitude:35.658034 , longitude: 139.701636 };
// 対象エリアのジオハッシュリスト
var areaGeohashList = getArea(center, radius);
// 100メートル以内に入っているものだけを抽出
areaGeohashList.forEach(o => {
if (geolib.getDistance(center, o.point) < radius) {
console.log(o.geohash);
}
});
結果はこうなりました。(長いので横並べ)
xn76fgpp,xn76fgpq,xn76fgpr,xn76fgpw,xn76fgpx,xn76fgpy,xn76fgpz,xn76fgqc,xn76fgqf,xn76fgqg,xn76fgqu,xn76fgr0,xn76fgr1,xn76fgr2,xn76fgr3,xn76fgr4,xn76fgr5,xn76fgr6,xn76fgr7,xn76fgr8,xn76fgr9,xn76fgrb,xn76fgrc,xn76fgrd,xn76fgre,xn76fgrf,xn76fgrg,xn76fgrh,xn76fgrj,xn76fgrk,xn76fgrm,xn76fgrn,xn76fgrq,xn76fgrr,xn76fgrs,xn76fgrt,xn76fgru,xn76fgrv,xn76fgrw,xn76fgrx,xn76fgry,xn76fgrz,xn76g50p,xn76g520,xn76g521,xn76g523,xn76g524,xn76g525,xn76g526,xn76g527,xn76g52h,xn76g52j,xn76g52k,xn76g52n
地図に表示するとこうなりました。精度を上げたければ、ジオハッシュの桁数を上げればOKです。
クエリで抽出
あとは上記のジオハッシュを普通にクエリで引っ掛ければOKです。
select *
from user_geometory_log
where
geohash_8 in ('xn76fgpp','xn76fgpq','xn76fgpr','xn76fgpw','xn76fgpx','xn76fgpy','xn76fgpz','xn76fgqc','xn76fgqf','xn76fgqg','xn76fgqu','xn76fgr0','xn76fgr1','xn76fgr2','xn76fgr3','xn76fgr4','xn76fgr5','xn76fgr6','xn76fgr7','xn76fgr8','xn76fgr9','xn76fgrb','xn76fgrc','xn76fgrd','xn76fgre','xn76fgrf','xn76fgrg','xn76fgrh','xn76fgrj','xn76fgrk','xn76fgrm','xn76fgrn','xn76fgrq','xn76fgrr','xn76fgrs','xn76fgrt','xn76fgru','xn76fgrv','xn76fgrw','xn76fgrx','xn76fgry','xn76fgrz','xn76g50p','xn76g520','xn76g521','xn76g523','xn76g524','xn76g525','xn76g526','xn76g527','xn76g52h','xn76g52j','xn76g52k','xn76g52n')
おわりに
ジオハッシュ以外にも、S2やOLCといった、位置情報をハッシュで表す体系があるので、気が向いたら調べてみるといいです。
個人的にはジオハッシュが一番扱いやすいと思っています。
参考
PostGIS: https://postgis.net/
ジオハッシュ: http://bekkou68.hatenablog.com/entry/2014/10/29/000656
図の作成は、Google Map APIを使っています。
Google Map APIもいつか記事にします。