PHPサーバなしで逆ジオコーディングしてみる
- 諸般の事情により、CORS有効化不能 or PHPサーバが用意できなかった時に作成
- もう用済みですが、せっかく作った+WebWorkerのお勉強をしたため供養
概要
以前の記事、逆ジオコーディングサーバを自作でヒュベニの式を用いて直接緯度経度から最近傍を探していましたが、今回はgeohashを使用しています。
https://github.com/davetroy/geohash-js/
(精度がイマイチ、容量削減効果も微小、パフォーマンスも実用上変わらずのため、ヒュベニの式に移行)
- あらかじめ住所とgeohashの対応表を用意して、javascriptの変数の形でファイル保存
- 初回利用時に上記ファイルを取り込み、indexedDBに格納
- WebWorkerを起動し、indexedDBへのクエリを担当
- 非同期処理がたくさんです
コード
実際にウェブワーカーとして働くクラス
webworker.js
'use strict'
/*
バックグラウンドでリバースジオコーディングを実施するウェブワーカー
GeoHash,IndexedDBを利用して緯度経度から住所を返す。
https://github.com/davetroy/geohash-js/をクラス化
*/
class geohashjs{
constructor(){
this.BITS = [16, 8, 4, 2, 1];
this.BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
const NEIGHBORS = { right : { even : "bc01fg45238967deuvhjyznpkmstqrwx" },
left : { even : "238967debc01fg45kmstqrwxuvhjyznp" },
top : { even : "p0r21436x8zb9dcf5h7kjnmqesgutwvy" },
bottom : { even : "14365h7k9dcfesgujnmqp0r2twvyx8zb" } };
const BORDERS = { right : { even : "bcfguvyz" },
left : { even : "0145hjnp" },
top : { even : "prxz" },
bottom : { even : "028b" } };
NEIGHBORS.bottom.odd = NEIGHBORS.left.even;
NEIGHBORS.top.odd = NEIGHBORS.right.even;
NEIGHBORS.left.odd = NEIGHBORS.bottom.even;
NEIGHBORS.right.odd = NEIGHBORS.top.even;
BORDERS.bottom.odd = BORDERS.left.even;
BORDERS.top.odd = BORDERS.right.even;
BORDERS.left.odd = BORDERS.bottom.even;
BORDERS.right.odd = BORDERS.top.even;
this.NEIGHBORS = NEIGHBORS;
this.BORDERS = BORDERS;
}
neighbors(hash){
const arr = [];
const right = this.calculateAdjacent(hash, 'right');
const left = this.calculateAdjacent(hash, 'left');
arr.push(this.calculateAdjacent(hash, 'top'));
arr.push(this.calculateAdjacent(hash, 'bottom'));
arr.push(this.calculateAdjacent(left, 'top'));
arr.push(this.calculateAdjacent(right, 'top'));
arr.push(this.calculateAdjacent(right, 'bottom'));
arr.push(this.calculateAdjacent(left, 'bottom'));
arr.push(right);
arr.push(left);
return arr;
}
calculateAdjacent(srcHash, dir) {
srcHash = srcHash.toLowerCase();
const lastChr = srcHash.charAt(srcHash.length-1);
const type = (srcHash.length % 2) ? 'odd' : 'even';
let base = srcHash.substring(0,srcHash.length-1);
if (this.BORDERS[dir][type].indexOf(lastChr)!=-1)
base = this.calculateAdjacent(base, dir);
return base + this.BASE32[this.NEIGHBORS[dir][type].indexOf(lastChr)];
}
//未使用
decodeGeoHash(geohash) {
let is_even = 1;
const lat = [], lon = [];
lat[0] = -90.0; lat[1] = 90.0;
lon[0] = -180.0; lon[1] = 180.0;
for (let i=0; i<geohash.length; i++) {
const c = geohash[i];
let cd = this.BASE32.indexOf(c);
for (let j=0; j<5; j++) {
const mask = this.BITS[j];
if (is_even) {
if (cd&mask) lon[0] = (lon[0] + lon[1])/2;
else lon[1] = (lon[0] + lon[1])/2;
} else {
if (cd&mask) lat[0] = (lat[0] + lat[1])/2;
else lat[1] = (lat[0] + lat[1])/2;
}
is_even = !is_even;
}
}
lat[2] = (lat[0] + lat[1])/2;
lon[2] = (lon[0] + lon[1])/2;
return { latitude: lat, longitude: lon};
}
encodeGeoHash(latitude, longitude) {
let is_even=1,bit=0,ch=0,geohash = "";
const lat = [], lon = [], precision = 8;
lat[0] = -90.0; lat[1] = 90.0;
lon[0] = -180.0; lon[1] = 180.0;
while (geohash.length < precision) {
if (is_even) {
const mid = (lon[0] + lon[1]) / 2;
if (longitude > mid) {
ch |= this.BITS[bit];//ビット和
lon[0] = mid;
} else lon[1] = mid;
} else {
const mid = (lat[0] + lat[1]) / 2;
if (latitude > mid) {
ch |= this.BITS[bit];
lat[0] = mid;
} else lat[1] = mid;
}
is_even = !is_even;
if (bit < 4) bit++;
else {
geohash += this.BASE32[ch];
bit = 0;
ch = 0;
}
}
return geohash;
}
}
const Geo = new geohashjs();
//検索用のコードセットを用意
const BASE32_CODES = "0123456789bcdefghjkmnpqrstuvwxyz";
const BASE32_CODES_DICT = {};
for (let i=0; i<BASE32_CODES.length; i++) {
BASE32_CODES_DICT[BASE32_CODES.charAt(i)] = i;
}
//DBの用意
const storeName = 'geo';
const req = indexedDB.open('geoDB');
const geohash = [];
//dbがあればこちらが実行される
req.onsuccess = (event) => {
const db = event.target.result;
const trans = db.transaction(storeName, 'readonly');
const store = trans.objectStore(storeName);
// 全件取得(cursorで検索すると時間がかかるため、起動時に全部メモリへ)
const allReq = store.getAll();
allReq.onsuccess = (ev) => {
const items = ev.target.result;
//文字列変数からハッシュテーブル作成
for(const c of items){
//id:geoHash addr:住所 code:国土地理院コード
geohash.push({hash:c['hash'],pref:c['pref'],city:c['city'],district:c['district']})
}
req.result.close();
}
allReq.onerror = (ev) =>{
console.log(ev);
req.result.close();
}
}
//dbがない場合に実行
req.onupgradeneeded = (event)=>{ throw event;}
req.onerror = (event)=>{ throw event;}
//メッセージ受信時の処理
self.addEventListener('message', (message) => {
if(Array.isArray(message.data)){//DB作成用[latitude,longitude]
const lat = message.data[0];
const lng = message.data[1];
const hash = Geo.encodeGeoHash(lat,lng);
self.postMessage(hash);
}else if(typeof message.data === "object"){
//L.latLngオブジェクトの場合
const addr = regeo(message.data);
self.postMessage(addr);
}else{
self.postMessage(false);
}
});
//geohashを用いして逆ジオ
function regeo(latlng){
const result = {
accuracy:null,
geo:null
};
const targetHash = Geo.encodeGeoHash(latlng.lat,latlng.lng);
//通常、近傍の複数個所がヒットするため、まず候補地を選出
let candidate = []//concatするためconstにしない
//ハッシュ文字の後ろから一文字づつ削っていき、ヒットするまでループ
//4桁では誤差が20~30kmになるため、そこでうちどめ。
//8桁の20~30m範囲でもまずヒットしないため、7桁目から照合
for(let i=7;i>3;i--){
const searchHash = targetHash.substr(0, i)
const targetResult = geohash.filter((v) => v.hash.startsWith(searchHash));
if(targetResult.length > 0){
candidate = candidate.concat(targetResult);
//みつかってもbreakしない。グリッド境界付近では最近傍とは限らないため。
}
//位置情報ぴったりヒットもまずないので、検索場所の近傍グリッドも取得
const neighbor = Geo.neighbors(searchHash);
//const neighbor = ngeohash.neighbors(searchHash);
for(const n of neighbor){
//同一ハッシュ文字数の近傍検索はヒットしてもそのループが終わるまで検索続行
//複数ヒット時にどの地点が最近傍か不明なため
const neighborReault = geohash.filter((v) => v.hash.startsWith(n));
if(neighborReault.length > 0) candidate = candidate.concat(neighborReault);
}
if(candidate.length > 0){
result['accuracy'] = i;
break;
}
}
if(candidate.length === 0) return result;
//ハッシュ文字列を2進数に戻す
const toBin=(s)=>{
let bin = '';
for(const i of s){
const num = BASE32_CODES_DICT[i]
bin = bin + ("00000000" + num.toString(2)).slice(-8)
}
return parseInt(bin,2);
}
//候補地が複数ある場合、一番近い地点を検索
if(candidate.length > 1){
const targetBin = toBin(targetHash);
let min = Infinity;
for(const c of candidate){
const cbin = toBin(c.hash);
//検索ハッシュと候補地のxorをとって最小のものを選出
const xor = targetBin ^ cbin;
if(xor < min){
min = xor;
result['geo'] = c;
}
}
}else{
result['geo'] = candidate[0];
}
return result;
}
初回のDB作成やウェブワーカー登録、クエリ送受信を担当
reversegeocoder.js
/*
ジオロケーション用クラス
IndexedDB、WebWorker仕様
コンストラクタで、IndexedDB作成、webworker起動
IndexedDBはWebWorkerから読取
データベースは国土地理院位置参照情報を利用
*/
'use strict'
class GeoLocation{
static gis;
constructor(){
const target = this;//コールバックの嵐のため現時点のthisを保存
if(gReGeoUrl === undefined){
//indexeddbの存在確認,なければ新規作成
this.dbName = 'geoDB';
this.regeostoreName = 'geo';
const req = indexedDB.open(this.dbName);
//CORS回避のためのハック、必要ないなら通常のimport文でOK
const url = Util.convertLocalJs2url('./WebWorkerGeocoder.js');
return new Promise((resolve, reject)=>{
//初回以外はこちらが実行される
req.onsuccess = () => {
target.worker = new Worker(url);
resolve(target);
}
//dbがない初回起動時に実行
req.onupgradeneeded = async(event)=>{
window.alert("初回起動\n全国住所データベースを読み込み中です。\nしばらくお待ちください。");
const initDb = event.target.result;
initDb.createObjectStore(this.regeostoreName, {keyPath:'hash'});
//初回のみdbを取り込む、二回目以降はonupgradeneededが呼ばれないためこれも実行されない
req.onsuccess = async(event)=>{
console.log('init db open success');
const db = event.target.result;
//regeoデータの動的読み込み
const loadok = await Util.asyncLoadJS('./geoDB.js').catch((e)=>console.log(e));
if(loadok){
//まず、逆ジオロケーション用DBの取り込み
const regeotrans = db.transaction(this.regeostoreName, 'readwrite');
const regeostore = regeotrans.objectStore(this.regeostoreName);
const csv = Papa.parse(gGeoDBText,{header: true});
for(const c of csv.data){
const str = {hash:c['hash'],pref:c['pref'],city:c['city'],district:c['district']};
regeostore.add(str);
}
// トランザクション完了時に実行
regeotrans.oncomplete = ()=>{
console.log('transaction complete');
target.worker = new Worker(url);
resolve(target);
}
}
}
}
req.onerror = ()=>{
// 接続に失敗
console.log('regeodb load error')
reject();
}
});
}else{
return new Promise((resolve, reject)=>{resolve(target)});
}// end of if
}
resetDB(){
if(gReGeoUrl || !window.confirm("住所DBを消去してよろしいですか?")){
return;
};
const req = indexedDB.deleteDatabase(this.dbName);
req.onsuccess = (e)=>{
console.log(e);
window.alert('DBの削除に成功しました。\nツールの再起動時にDBを再作成します。');
}
req.onerror = (e)=>{
console.log(e);
window.alert(e + "\nDBの削除に失敗しました。")
}
}
//L.latlng(緯度経度)から住所を検索
async query(latlng,drawHtml){
//webworkerへクエリをなげる
this.worker.postMessage(latlng)
return new Promise((resolve, reject)=>{
this.worker.onmessage = (async(e)=>{
const addr = (e.data['geo'] !== null)
? e.data['geo'].pref + e.data['geo'].city + e.data['geo'].district
: '';
resolve(addr)
});
});
}
緯度経度と住所の対応表を作成
//国土地理院位置参照情報を使用して逆ジオ用DBを作成
async createGeoDB(f){
const getHash=(latlng)=>{
this.worker.postMessage(latlng);
return new Promise((resolve, reject)=>{
this.worker.onmessage = (async(e)=>{
resolve(e);
});
});
}
//自作の大容量テキストを行毎の配列として読み出すクラス
const rd = new LargeFileReader();
const csv = await rd.asyncParseCSV(f,"shift-jis",true);
const arr = []
for(const c of csv){
const hash = await getHash([c["緯度"],c["経度"]]);
const t = c["都道府県名"] + c["市区町村名"] + c["大字・丁目名"] + "," + c["小字・通称名"] + "," +c["街区符号・地番"] +"," +hash.data;
arr.push(t)
}
const text = arr.join('\n');
Util.fileDownload(text,".json");
}
}