Calmery.me

みっかぼうずにならないようがんばる

three.jsと標高タイルで3Dマップを生成する

今日はクリスマスイブですね.皆様いかがお過ごしでしょうか.かく言う私はお布団にくるまり http://qiita.com/advent-calendar/2016/no-girlfriend を眺めています.お察しください.

ブラウザ上で現在位置を中心とした 3D マップを 国土地理院の標高タイルthree.js で作ることができたのでメモ.今月の 17,18 日に開催された 林業応援ハッカソン で作った.
github.com
流れとしてはまず現在位置を取得し,その取得した現在位置からタイル座標を求める.ちなみに私は初めてタイル座標というものを知った.このタイル座標や後々利用するピクセル座標については TrailNote : 座標の変換(世界座標、ピクセル座標、タイル座標、緯度・経度) が参考になる.次に求めたタイル座標を元に 国土地理院の標高タイル から標高データを取得し three.js で描画する.

タイル座標を求める

緯度と経度からタイル座標を求める.Showing pixel and tile coordinates  |  Google Maps JavaScript API  |  Google Developers を参考にした.

const createCoordinate = ( lat, lng ) => {
    const TILE_SIZE  = 256,
          ZOOM_LEVEL = 14
    return createInfoWindowContent( ( new google.maps.LatLng( lat, lng ) ), ZOOM_LEVEL, TILE_SIZE )
}

const createInfoWindowContent = ( latLng, zoom, tileSize ) => {
    const scale = 1 << zoom
    
    const worldCoordinate = ( ( latLng, tileSize ) => {
        const siny = Math.min( Math.max( ( Math.sin(latLng.lat() * Math.PI / 180) ), -0.9999 ), 0.9999 )
        return new google.maps.Point( tileSize * ( 0.5 + latLng.lng() / 360 ), tileSize * ( 0.5 - Math.log( ( 1 + siny ) / ( 1 - siny ) ) / ( 4 * Math.PI ) ) )
    } )( latLng, tileSize )

    const pixelCoordinate = new google.maps.Point( Math.floor( worldCoordinate.x * scale ), Math.floor( worldCoordinate.y * scale ) ),
          tileCoordinate  = new google.maps.Point( Math.floor( worldCoordinate.x * scale / tileSize ), Math.floor( worldCoordinate.y * scale / tileSize ) )

    return {
        lat            : latLng.lat(),
        lng            : latLng.lng(),
        zoomLevel      : zoom,
        pixelCoordinate: { x: pixelCoordinate.x, y: pixelCoordinate.y },
        worldCoordinate: { x: worldCoordinate.x, y: worldCoordinate.y },
        tileCoordinate : { x: tileCoordinate.x, y: tileCoordinate.y }
    }
}

標高データを取得する

タイル座標を使い,標高データを CSV で取得する.また,タイル座標に対応したテクスチャも取得することができる.ただし,国土地理院の標高タイルではズームレベルが 14 で固定されており,他のズームレベルでは標高データを取得することができないことに注意する.

標高データ
http://cyberjapandata.gsi.go.jp/xyz/dem/ZOOM_LEVEL/TILE_COORDINATE_X/TILE_COORDINATE_Y.txt
http://cyberjapandata.gsi.go.jp/xyz/dem/14/14547/6463.txt
テクスチャ
http://cyberjapandata.gsi.go.jp/xyz/relief/ZOOM_LEVEL/TILE_COORDINATE_X/TILE_COORDINATE_Y.png
http://cyberjapandata.gsi.go.jp/xyz/relief/14/14547/6463.png

取得した標高データは適当に処理して配列に変換する.

mapData = mapData.substring( 0, mapData.length-1 ).split( '\n' ).map( ( row ) => {
    return row.split( ',' ).map( ( height ) => {
        return parseFloat( height ) || -1
    } )
} )

あとは three.js の PlaneGeometry で元となる Plane を作成し,取得した標高データを元に各点の高さを変更する.

let geometry = new THREE.PlaneGeometry( 0, 0, xLength-1, yLength-1 )

for( let y=0; y<yLength; y++ )
    for( let x=0; x<xLength; x++ )
        geometry.vertices[x + ( y * xLength )] = { x: x, y: y, z: mapData[y][x] }

自分を中心とした 3D マップを生成する

現在位置からタイル座標を求める場合,自分の現在位置に対応したタイル座標を得ることができる.だがタイル上のどの位置にいても同じタイル座標を得てしまう.まあタイル座標というくらいだし当たり前なのだろうけど.またまた TrailNote : 座標の変換(世界座標、ピクセル座標、タイル座標、緯度・経度) を参考にした.自分の現在位置を元に 3D マップを作成しても自分を中心としたマップは作成されないのでいい感じにゴニョゴニョする.

仕方がないので差分をとって取得した標高データを合成する.タイル座標からピクセル座標を求めて現在位置のピクセル座標と比較する.自分がタイルのどの辺りにいるのかを調べて足りない標高データを取得するようにした.
f:id:calmery:20161224164312p:plain
もっとスマートにやりたかった.恥ずかしい.mori_hack/main.js at master · calmery/mori_hack · GitHub に頑張ったあとがある.

const targetTile = createCoordinate( position.lat, position.lng )

const difference = ( ( targetTile ) => {
    let t = targetTile.tileCoordinate,
        p = targetTile.pixelCoordinate

    let tileToLatLng = ( tx, ty, zoom ) => {
        let numOfTiles = Math.pow( 2, zoom ),
            x = tx / numOfTiles,
            y = ty / numOfTiles,
            latRadians = ( y - ( 1 / 2 ) ) / - ( 1 / ( 2 * Math.PI ) )
        return { lat: ( 2 * Math.atan( Math.exp( latRadians ) ) - Math.PI / 2 ) / Math.PI * 180,  lng: ( x - ( 1 / 2 ) ) / ( 1 / 360 ) }
    }

    let latLng = tileToLatLng( t.x, t.y, t.z ),
        tile = createCoordinate( latLng.lat, latLng.lng )

    return { x: p.x - tile.pixelCoordinate.x, y: p.y - tile.pixelCoordinate.y }
} )( targetTile )

const essentialTile = ( ( targetTile, difference ) => {
    let tx = targetTile.tileCoordinate.x,
        ty = targetTile.tileCoordinate.y

    let tiles = [ [], [] ],
        differenceFromBase = {}

    if( difference.x <= 128 && difference.y <= 128 ){
        tiles[0][0] = { x: -1, y: -1 }
        tiles[0][1] = { x:  0, y: -1 }
        tiles[1][0] = { x: -1, y:  0 }
        tiles[1][1] = { x:  0, y:  0 }

        differenceFromBase.x = 256 + difference.x
        differenceFromBase.y = 256 + difference.y
    } else if( difference.x > 128 && difference.y <= 128 ){
        ...
    } else if( difference.x <= 128 && difference.y > 128 ){
        ...
    } else if( difference.x > 128 && difference.y > 128 ){
        ...
    }

    return { tiles: tiles, difference: differenceFromBase }
} )( targetTile, difference )

足りない標高データを補うため合計で 4 回,標高データを取得する必要がある.配列に現在位置より求めたタイル座標からどの程度ずれているかの情報を格納している.上の図の場合,こんな配列になる.

[
    [{ x: -1, y: -1 }, { x:  0, y: -1 }],
    [{ x: -1, y:  0 }, { x:  0, y:  0 }]
]

この配列を元に標高データを取得し,あとはタイル座標から求めたピクセル座標と,現在位置のピクセル座標の差分を元に,足りない標高データを補った配列を作る.テクスチャも同様に 4 枚取得し canvas に貼り付けて切り取る.人吉駅を中心とするとこんな感じの 3D マップができる.f:id:calmery:20161224165333p:plain
ちょっとわかりにくいが中心に人吉駅がある.Google Map で見るとちゃんと中心にきていることがわかるかも.

まとめ

Unity を使って OpenStreetMap Japan | 自由な地図をみんなの手に/The Free Wiki World Map とかと組み合わせると面白そうだと思った.mori_hack/sample at master · calmery/mori_hack · GitHub にすぐに動かせるものがあるのでお試しあれ.

Gitのコミットメッセージを変更する

commit 48a2a97df5dd5f15241cb0dc6635c66b3a6b6c3f
Author: calmery <contact@calmery.me>
Date:   Sun Dec 18 13:33:08 2016 +0900

    Change layout

commit 9b8c43a562f3a1e095541bdaca4a58c76f6144c2
Author: calmery <ontact@calmery.me>
Date:   Sun Dec 18 12:44:53 2016 +0900

    Merge
$ git rebase -i 9b8c43a562f3a1e095541bdaca4a58c76f6144c2

変更したいコミットの部分の pick を edit に変更する.

pick db3d0e8 Change layout
=>
edit db3d0e8 Change layout

コミットの内容を変更できる.コミットをひとつひとつ変更できるみたい.

$ git commit --amend
$ git rebase --continue
...
$ git commit --amend
$ git rebase --continue

プッシュする.

$ git push -f

ElectronでTwitterのOAuth認証をする

これは Electron Advent Calendar 2016 - Qiita 15 日目の記事です.

はじめに

Electron で Twitter と連携したアプリケーションを作りたいけれど,どうすればアプリケーションから Oauth 認証できるのかということで作りました.
github.com

$ git clone https://github.com/calmery/ElectronAdventCalendar2016.git
$ cd ElectronAdventCalendar2016
$ npm -g install electron-prebuilt
$ npm install
$ electron .
// common.js
// Twitter API Token
consumerKey: 'YOUR_CONSUMER_KEY',
consumerKeySecret: 'YOUR_CONSUMER_KEY_SECRET',

実装

Node.js で Web サーバを立てて,それを Electron から操作するといった感じ.Node.js と Electron の間は socket.io を使ってやり取りを行うようにした.Web サーバには http モジュールと express を使っている.

実際に http モジュールを使い Web サーバを立てるときにポート番号を指定することができるが,自動でポート番号を割り当てることもできる.こちらからポート番号を指定すると,ポート番号が競合してしまったり,他のブラウザなどから開けてしまうので自動で割り当てた方が個人的にはいい気がする.

// ポート番号を指定して実行
server.listen( 3000 );

// ポート番号を指定せずに実行
server.listen();

// ポート番号を取得
server.listen().address().port;

passport を使って認証する

肝心の認証だが passport を使うと簡単にできた.詳しくは Node.js + Express + passport-twitterで認証Webアプリをつくる - Qiita などを参考にすると良さそう.API キーの指定とルーティングをするだけでよかった.

const passport        = require( 'passport' ),
      twitterStrategy = require( 'passport-twitter' ),
      session         = require( 'express-session' );

app.use( passport.initialize() );
app.use( passport.session() );
app.use( session( { secret: 'lectern' } ) );

app.get( '/oauth', passport.authenticate( 'twitter' ) );
app.get( '/callback', passport.authenticate( 'twitter', { 
    successRedirect: '/',
    failureRedirect: '/fail' 
} ) );

passport.serializeUser( function( user, done ){ done( null, user ) } );
passport.deserializeUser( function( user, done ){ done( null, user ) } );

passport.use( new twitterStrategy.Strategy( {
    consumerKey   : 'CONSUMER_KEY',
    consumerSecret: 'CONSUMER_KEY_SECRET',
    callbackURL   : 'http://127.0.0.1:' + port + '/callback'
}, function( token, tokenSecret, profile, done ){
    console.log( token, tokenSecret );
    ...
} ) );

js-yamlトークンを保存する

取得したトークンは js-yaml を使用して保存した.

// common.js
const jsYaml = require( 'js-yaml' );

module.exports = {
    
    getAbsolutePath: function(){
        return path.resolve( path.join.apply( this, [].slice.call( arguments ) ) );
    },
    
    writeFileSync: function( filePath, content ){
        try {
            fs.writeFileSync( this.getAbsolutePath( filePath ), content );
            return true;
        } catch( error ){
            return false;
        }
    },
    
    jsYaml: jsYaml
    
};

// express.js
const common = require( './common' );

common.writeFileSync( 'user.yaml', common.jsYaml.safeDump( {
    access_token: token,
    access_token_secret: tokenSecret
} ) );

こんな YAML ファイルが作られる.

access_token: YOUR_ACCESS_TOKEN
access_token_secret: YOUR_ACCESS_TOKEN_SECRET

ちなみにアプリケーションを立ち上げたとき,このアクセストークンを保存したファイルが無ければ認証ページにリダイレクトするようにしている.

app.get( '/', function( request, response ){
    if( common.exists( 'user.yaml' ) ) 
        response.sendFile( common.getAbsolutePath( 'public/index.html' ) );
    else 
        response.redirect( '/oauth' );
} )

TwitterAPI を使う

Twitter API の呼び出しは関数にまとめた.

// twitter.js
module.exports.getClient = function(){

    const config = common.yamlLoader( 'user.yaml' );

    const client = new twitter( {
        consumer_key       : common.consumerKey,
        consumer_secret    : common.consumerKeySecret,
        access_token_key   : config.access_token,
        access_token_secret: config.access_token_secret
    } );

    return {

        getUserTweet: function( condition ){
            return new Promise( function( resolve, reject ){
                client.get( 'statuses/user_timeline', makeOption( condition ), function( error, tweet, response ){
                    if( error === null ) 
                        resolve( tweet );
                    else 
                        reject( error );
                } );
            } );
        },
        ...
// index.js
io.sockets.on( 'connection', function( socket ){
    
    client = twitter.getClient()
    
    const emit = function( key, content ){
        io.sockets.to( socket.id ).emit( key, content );
    };
    
    const emitError = function( content ){
        emit( 'error', content );
    };
    
    socket.on( 'getUserTweet', function( user_id ){
        client.getUserTweet( user_id ).then( function( tweet ){
            emit( 'userTweet', tweet );
        }, emitError );
    } );
    
} );

ツイートを表示する

Electron 側での表示はこんな感じ.

<script src="/socket.io/socket.io.js"></script>
<script src="resources/preload.js"></script>
<script src="resources/index.js"></script>
// preload.js
var socket = io()

socket.on( 'error', function( error ){
    console.log( error )
} )

// index.js
socket.emit( 'getUserTweet', 'calmeryme' )

socket.on( 'userTweet', function( tweet ){
    
    for( var i=0; i<tweet.length; i++ )
        document.body.innerHTML += tweet[i].text + '<br><br>'
    
} )

指定したユーザのツイートを表示できた.
f:id:calmery:20161214211134p:plain

まとめ

Electron を使って Oauth 認証し,ユーザのツイートを取得することができた.Web の知識だけで簡単にアプリケーションを作ることができるのは本当にありがたい.Electron 万歳.あとは electron-packager を使って配布できる形式にすればいい.パッケージ化したアプリケーションの容量が大きくなってしまうことが難点だがよりアプリケーションっぽくなる.30分で出来る、JavaScript (Electron) でデスクトップアプリを作って配布するまで - Qiita が参考になった.もうほんとライブラリさまさまなので感謝しかない><