2014-10-28

JavaScriptでデバイスを左右に傾けて移動するユーザーインタフェース

デバイスの向きが取得できるようになったところなので、実際の応用を考えてみることにします。画面の中に何かが表示されていて、右に傾けたら右へ、左に傾けたら左へ移動するようなユーザーインタフェースというのはどうでしょう。

左右の傾きを検出するにはどうしたら良いでしょうか。

さて、そもそも「左右の傾き」とはいったい何でしょうか。

deviceorientationのgammaでしょうか? gammaはデバイスのY軸、つまり画面縦方向を軸とした回転角でした。確かに机の上にデバイスを水平において、少し左右に傾けたときの姿勢はY軸回りの回転だけで表されそうです。

しかしデバイスを手で持って顔の前で使っている場合はどうでしょう。左右に傾けるというのはZ軸(画面奥から手前に向けての軸)回りの回転ではないでしょうか。

仰向けに寝そべって天井に向けてデバイスを持っている場合はどうでしょう。これはY軸の回転で左右操作をしそうですね。人によってはZ軸で操作するかもしれません。

これらに共通するものは何でしょうか。人は左右の傾きを何を基準に入力しているのでしょうか。

おそらく、X軸(デバイス横方向の軸)の傾きではないでしょうか。つまり、X軸の正方向(右方向)が地を向けば右、逆に天を向けば左なのではないでしょうか。これはデバイス(板)の上に乗っているボールが重力に沿って左右に動く様を想像しているのかもしれません。

となれば、デバイスのX軸が地面に対して何度傾いているかを計算すれば、左右の傾きになりそうです。

デバイスの向きがdeviceorientationイベントのalpha, beta, gammaで表されるとき、デバイス座標rから地球座標r'への変換は次の式になるのでした。

r' = matRotZ(alpha)*matRotX(beta)*matRotY(gamma)*r

(rとr'は列ベクトル、matRotZ(alpha)はZ軸回りにalpha度回転する変換行列)

今知りたいのはデバイスのX軸正方向を地球座標へ変換したときにZ軸(地面に対して垂直な軸)方向が正になるか負になるかです。

デバイスのX軸正方向を代表する座標 r=[1,0,0] を変換元にして、行列を全て結合すると次のようになります。

r'= [ cos(alpha)*cos(gamma)-sin(alpha)*sin(beta)*sin(gamma) ]
    [ sin(alpha)*cos(gamma)+cos(alpha)*sin(beta)*sin(gamma) ]
    [ -cos(beta)*sin(gamma)                                 ]

つまり、r'_z=-cos(beta)*sin(gamma) が答えになります。これが正なら左に傾き、負なら右に傾いていることになります。

地面と平行な面との角度を知りたければsin^-1をとります。斜辺が長さ1のデバイスX軸正方向に沿ったベクトル、垂線がr'_zの三角形を思い浮かべれば分かると思います。

th = sin^-1(-cos(beta)*sin(gamma))

これだと画面の回転を考慮していません。デバイスの自然な向きで使用しているときは問題ありませんが、portraitやlandscapeといった画面の向きを変えるとおかしな事になります。

それを補正するために、screen.orientation.angleを使用します(もしこのプロパティが使えないならlockOrientationで画面の向きを固定するか、諦めて他の入力方法に切り替えた方が良いかもしれません)。

画面上の座標からデバイス座標へ変換するには、screen.orientation.angleだけZ軸まわりに逆回転させます。なので、画面上の座標から地球座標へ変換する式は次のようになります。

r' = matRotZ(alpha)*matRotX(beta)*matRotY(gamma)*matRotZ(-screen.orientation.angle)*r

先ほどと同じように行列を結合すると次のようになります。

angle = -screen.orientation.angle
r' = [ cos(alpha)*cos(gamma)*cos(angle) -sin(alpha)*( cos(beta)*sin(angle)+sin(beta)*sin(gamma)*cos(angle) ) ]
     [ sin(alpha)*cos(gamma)*cos(angle) +cos(alpha)*( cos(beta)*sin(angle)+sin(beta)*sin(gamma)*cos(angle) ) ]
     [ sin(beta)*sin(angle)-cos(beta)*sin(gamma)*cos(angle)                                                  ]

角度は次のようになります。

th = sin^-1(sin(beta)*sin(angle)-cos(beta)*sin(gamma)*cos(angle))

最終的なJavaScriptのコードは次のようになります。

function toRad(deg){return deg*(Math.PI/180);}
function toDeg(rad){return rad*(180/Math.PI);}
function getLeftTiltAngle(ev){
    // r = rotz(alpha)*rotx(beta)*roty(gamma)*rotz(-screen.orientation.angle)*colvec[1,0,0]
    // tilt = sin^-1(r.z)
    var b = toRad(ev.beta);
    var c = toRad(ev.gamma);
    var d = toRad(-screen.orientation.angle);
    var rz = Math.sin(b)*Math.sin(d)-Math.cos(b)*Math.sin(c)*Math.cos(d);
    return toDeg(Math.asin(rz));
}

以上の論法はDeviceOrientation Event SpecificationのWorked Exampleに書いてあるのとほとんど同じです。そちらはデバイス背面方向の方位を求める例ですね。

他にもdevicemotionイベントを使用して傾きを求める方法もあります。傾きを求めるだけならばコンパス(方位)の情報は不要なので、むしろこちらの方が本筋かもしれません。accelerationからaccelerationIncludingGravityを引くと重力加速度のベクトルが得られます。そのベクトルからデバイスX軸正方向の成分だけを取り出せば良いのです。もちろん画面の向きを考慮するなら、X軸正方向のベクトルをscreen.orientation.angleでZ軸回りに逆回転させて、そのベクトルと重力加速度のベクトルとの内積を求めます。JavaScriptのコードにすると次のようになります。

function getLeftTiltAngleFromMotionEvent(motionEvent){
  var gravityX = motionEvent.acceleration.x - motionEvent.accelerationIncludingGravity.x;
  var gravityY = motionEvent.acceleration.y - motionEvent.accelerationIncludingGravity.y;
  var gravityZ = motionEvent.acceleration.z - motionEvent.accelerationIncludingGravity.z;
  var screenAngleRev = toRad(-screen.orientation.angle);
  var rightX = Math.cos(screenAngleRev);
  var rightY = Math.sin(screenAngleRev);
  var dotGR = rightX * gravityX  + rightY * gravityY;
  var rightGravity = dotGR / Math.sqrt(gravityX*gravityX+gravityY*gravityY+gravityZ*gravityZ);
  return -(90 - toDeg(Math.acos(rightGravity)));
}

両方の方法を試してみましたが、deviceorientationを使った方が若干安定しているように見えます。devicemotionを使った方は、デバイスを振ったときに角度が大きく変化してしまいました。

以下、動作例。対応しているブラウザだと赤いバーが左右に動くはず。

Pingback / Trackback