ノートPCやタブレットとかについてるウェブカメラを使って、Googleマップ ストリートビューでランニングできるRunning on StreetViewというウェブアプリを作りました。

動画はデバッグ用のモデルに走ってもらっていますが、ウェブカメラで走るなどのアクションに応じてストリートビューを進行できるアプリです。

色々検索したのですが、単純にウェブカメラを使ってできるものを見つけることができなかったので作りました。 同様のことをやるものは色々あるのですが、大体特別な機器が必要な感じでした。

必要なもの

  • ウェブカメラがついたデバイス
  • ブラウザ(Google Chrome + Macbook Proでテストしています)
  • Google Maps JavaScript API Key (トライアルモード以外の場合)

使い方

Screenshot

制限なく使うには自分でGoogle Maps JavaScript API Keyを取得する必要がありますが、 手軽に試せるトライアルモードがあります。

トライアルモードは次のようにすれば試せます。

  1. https://running-on-streetview.netlify.app/ を開く
  2. "Free Trial" ボタンを押す
  3. プロンプトで出るカメラのアクセスを許可する
  4. 走る!

あとは好きな場所のGoogle MapのURLをロードもできるので、好きな場所を走って進んでください!

Screenshot running-on-streetview

トライアルモードは30パノラマ(30遷移)ぐらいまでできるようにしています。

自分でGoogle Maps JavaScript API Keyを取得すれば、 制限なく利用できるので走り続けたい方は自分のAPIキーを利用してください。 次のサイトを参考にすれば、Google Maps JavaScript APIに限定したAPIキーを発行できます。

上記の記事でも書かれていますが発行したAPIキーにはGoogle Mapsでのみ利用できるように制限を入れておくと安全です。 また、同様にHTTPリファラー(ウェブサイト)の制限で https://running-on-streetview.netlify.app/* を入れておくと安全です。 APIキーには月に10000パノラマ程度の無料枠があるので、普通に使う分には無料枠で収まると思います。

発行したAPIキーは次のようにすれば利用できます。(一度入れれば、次からキーは記憶されます)

  1. https://running-on-streetview.netlify.app/ を開く
  2. APIキーをテキストエリアにいれる
  3. "Load" ボタンを押す
  4. プロンプトで出るカメラのアクセスを許可する
  5. 走る!

方向転換はマウスを使って操作するか、矢印キーのショートカットで視点を変更できます。 別デバイスのコントローラーを作ろうとしたのですが、進行方向を自動追従に絞ってシンプルにしたほうが楽しそうだったので作ってないです。

GamePad APIやWebRTCを使って別デバイスのコントローラーを作ろうとした名残もあるので、実装したい人はIssue/PRを作ってください。

仕組み

基本的にはGoogleマップストリートビューのJavaScript SDKを使って、動きに合わせて進めているだけです。

ウェブカメラの映像を取得して、一定回数分の描画から差分の中央値を取得して、その差分が一定値を超えたら動いているという判定にしています。 描画差分の判定はpixelmatchを使ってpixelの差分を見ています。

そのため、実際に走るだけじゃなくて手を振るとかでも反応します。 カメラで足を写せばスクワットとか、ジャンプに反応させるとかもできるので、進み方は自分で決められます。

カメラがついているデバイスなら使えると思うので、スマートフォンだと画面的に厳しいですが、iPadとかのタブレットならなんとか使える気はします。 タブレットなら腕立てとかに反応させて使うとかもできる気がします。 元ネタみたいなエアロバイクとかでも動く部分を写せば反応すると思います。(隠しオプションとして反応感度の設定などもできます)

Macbook Pro以外ではあんまりテストしてないので、デザイン崩れや改善などはPull Requestをお願いします。

デバッグにはMMDで作成した3Dモデルに走っているモーションをつけて動画にしたものを、Chromeの--use-file-for-fake-video-capture=<filename>が読み込んでいます。 デバッグの度に走るのは大変なので、デバッグ時は3Dモデルに走ってもらっています。

リポジトリを見てみるとわかりますが、このアプリはJavaScriptフレームワークを使わずに書いています。

そのためJavaScriptは15kbほどで済んでいます。(MapのSDKは別、一部Utilなライブラリを使っています・)

ファイルサイズ

Bundle Analyzerの結果は次のURLで見れます。

Lighthouseのスコアもこれだけ小さいアプリだとあんまり意識しなくてもちゃんとでるようでした。 (アクセシビリティ周りは見てからちょっと直した)

Lighthouse

あんまりUIない気がしたのでReactなどを使わずに書きはじめたのですが、操作ボタンやフォームとかちょっとしたUIが必要なことに書き始めてから気づきました。 そのため、このアプリでは次のようなシグネチャでコンポーネントを書くようにしています。

export type コンポーネントProps = {

}
export const コンポーネント = (containerElemenet: HTMLElement, props: コンポーネントProps) => {
    // 初期化処理
    return {
        update(props: Partial<コンポーネントProps>){
            // 更新処理
        },
        unload(){
            // 終了処理
        }
    }
}

たとえば、動作の状態をトグルステータスボタンは次のような感じです。

import "./StatusButton.css";

export type LoadMapProps = {
    onClick: () => void;
};

export function htmlToElement<T extends HTMLElement>(html: string): T {
    const template = document.createElement("template");
    template.innerHTML = html;
    return template.content.firstElementChild as T;
}

export type StatusButtonProps = { onClick(): void; text: string };
export const StatusButton = (controlContainer: HTMLElement, props: StatusButtonProps) => {
    const button = htmlToElement(`<button type="button" class="StatusButton pure-button"/>`);
    const onClick = (event: Event) => {
        event.preventDefault();
        props.onClick();
    };
    button.textContent = `Status: ${props.text}`;
    button.addEventListener("click", onClick);
    controlContainer.appendChild(button);
    return {
        update(props: Partial<StatusButtonProps>) {
            // TODO: イベントの再定義はいる?
            if (props.text) {
                button.textContent = `Status: ${props.text}`;
            }
        },
        unload() {
            button.removeEventListener("submit", onClick);
            controlContainer.removeChild(controlContainer);
        },
    };
};

使うときは次のようなイメージです。

const { update: updateStatusButton, unload: unloadStatusButton } = StatusButton(controlContainer, {
    text: state.playingStatus,
    onClick() {
        action.togglePlayingStatus();
    },
});
// なんか更新
updateStatusButton({
    text: "新しいテキスト"
});

更新処理がないパターンは次のようにいくつか省略しています。 たとえば、タブの表示状態を管理するコンポーネント(ここではUIじゃないけど)は次のような感じです。

export type VisibleControllerProps = {
    onVisibleChange(status: VisibilityState): void;
};
export const VisibleController = (props: VisibleControllerProps) => {
    const onVisibleChange = () => {
        // ignore on fullscreen
        if (document.fullscreenElement) {
            return;
        }
        props.onVisibleChange(document.hidden ? "hidden" : "visible");
    };
    document.addEventListener("visibilitychange", onVisibleChange);
    return () => {
        document.removeEventListener("visibilitychange", onVisibleChange);
    };
};

それぞれのコンポーネントに初期化と終了処理を書いておいて、unloadはまとめてできるようなイメージで書いています。 実際にはunloadがまだ必要になっていないのですが、ないと後で面倒そうなので最初から書いています。

    return () => {
        return Promise.all([
            streetViewPanorama.unbindAll(),
            unloadStreetView(),
            unloadLoadMap(),
            unloadStatusButton(),
            unloadVisibleController(),
            unloadShareButton(),
        ]);
    };

更新処理を行う方法がコンポーネントの初期化処理後に取得できるため、コントールフローを管理するindex.tsは若干ごちゃついてる気がします。 (多分イベントとか使ってうまくまとめてフローを一箇所で管理できるようにすると、フレームワークでよく見る流れになる気がする)

1000行程度のアプリ(1画面)なら、こんな感じでもまあまあ書けるものなんだなーという感想でした。

$ cloc src
      15 text files.
      15 unique files.
       0 files ignored.

github.com/AlDanial/cloc v 1.84  T=0.07 s (216.7 files/s, 14056.4 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
TypeScript                      12             32             54            872
CSS                              3              1              0             14
-------------------------------------------------------------------------------
SUM:                            15             33             54            886
-------------------------------------------------------------------------------

ビルド周りはParcelにまかせています。(v2が早くでるといいなー)

おわりに

一日でざっくりと作成したので、バグとかあったらPull Requestを送ってください。

あと、デフォルトでなぜか青森の蔦トンネルを走っているので、 もっとランニングに適したマップのURLを募集しています!

オススメの場所を#RunningOnStreetViewのハッシュタグに投稿してください。

参考