調布祭プラレール展示のWebUIと配信の話

UEC koken Advent Calendar 21日目の記事となります。

調布祭プラレール企画関連のブログ記事は以下から



Virtual調布祭

電気通信大学では今年(2020年)の11/21~23の3日間「バーチャル調布祭」と呼ばれるオンラインイベントが開催されていました。経緯については言わずもがなですが、従来の調布祭とはまた違うオンラインならではの良さがありました。

工研でも鉄道研究会さんと合同でプロジェクトを進めようという話になり、私も少しだけですが参加することにしました。 担当することになったのはいわばユーザー側、遠隔操作を行うウェブアプリケーションの部分でした。

作ったもの

特設サイトはこちら↓

chofufes-plarail-2020.takoyaki3.com

調布祭期間中は駅周辺や列車の様子を配信していました。

ソースについてはこちらから↓

github.com

設計

WebUI

さて、ユーザー側の心情としては深いギミックや仕様に悩まされることなく気軽に使える感覚が欲しくなるものです。 そのためUIを作るにあたってもあまりごちゃごちゃさせすぎないようにしよう、というのは最初に考えていました。

ユーザー側が遠隔操作でできることはこの2点に集約されます。

  • 操作可能区間京王本線)内での駅のポイント制御と発車・停車制御
  • 一部列車の速度制御

これに加えて操作する駅の選択も必要になります。ひとまず様々な人が使うことを想定してできるだけ直観的に操作できるような設計方針としました。

京王本線の駅は一部の再現にとどめ、どちらかというとポイント切り替えや通過待ちなどの”疑似運輸指令”を体験できるような駅が選ばれました。

一部の駅に絞って再現した路線図

遠隔操作できる駅は

の4駅、明大前駅以外は分岐があり遠隔制御の楽しさがより得られると思います。

駅選択部

例えば京王線をあまり知らないような人でも、これらの駅を初見でパッと見てわかりやすく選ぶ方法を考える必要がありました。

駅選択部。後にカメラを追加したため新線や前面展望のボタンを追加しました。

そのため路線図にボタンを重ねて配置することで位置関係を把握しやすくさせることにしました。

画像の上に重ねての配置はCSSのパーセント指定により行いました。いろいろなウィンドウ幅で表示が崩れてはいなかったため問題はなさそう、とは思いましたが正直もっと良いやり方があったようにも思います。

index.html

...(省略)

        <div id="chofusta" class="station" onclick="loadChofu()">調布駅</div>
        <div id="meidaista" class="station" onclick="loadMeidaimae()">明大前駅</div>
        <div id="sasasta" class="station" onclick="loadSasazuka()">笹塚駅</div>
        <div id="kitanosta" class="station" onclick="loadKitano()">北野駅</div>
        <div id="kudansta" class="station" onclick="loadKudansta()">九段下駅</div>
        <div id="iwamotosta" class="station" onclick="loadiwamotosta()">岩本町駅</div>
        <div id="train" class="station" onclick="loadtrain()">列車前面展望</div>
        <div id="backtokeio" class="station" hidden="true" onclick="backToWholeImage()">戻る</div>

...(省略)

css/main.css

...(省略)

.station {
    background-color: #2779bd55;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: calc(80% + 0.2vw);
    font-weight: bold;
    color: #000000;
    border-radius: 10%;
    cursor: pointer;
    text-shadow: 2px 2px 3px #ffffff99, -2px 2px 3px #ffffff99,
                 2px -2px 3px #ffffff99, -2px -2px 3px #ffffff99;
}

...(省略)

#chofusta {
    width: 20%;
    height: 16%;
    position: absolute;
    top: 52%;
    left: 52%;
}

#meidaista {
    width: 19%;
    height: 16%;
    position: absolute;
    top: 52%;
    left: 30%;
}

#kitanosta {
    width: 19%;
    height: 16%;
    position: absolute;
    top: 52%;
    left: 74%;
}

#sasasta {
    width: 20%;
    height: 16%;
    position: absolute;
    top: 52%;
    left: 7%;
}

#kudansta {
    width: 20%;
    height: 16%;
    position: absolute;
    top: 34%;
    left: 7%;
}

#train {
    width: 20%;
    height: 16%;
    position: absolute;
    top: 34%;
    left: 74%;
}

#iwamotosta {
    width: 20%;
    height: 16%;
    position: absolute;
    top: 34%;
    left: 30%;
}

#backtokeio {
    width: 12%;
    height: 10%;
    position: absolute;
    top: 10%;
    left: 44%;
}

ポイント制御と発車・停車制御部

ポイント切り替えやホームの発車や停車のオンオフは画像に重ねるとさすがにごちゃつく感じでしたので別に分けることにしました。 それぞれで役割が異なるため色分けしたりオンオフでの表示を変えたりといった工夫をしました。Bootstrapは便利ですね。

index.html

...(省略)
        <button v-for="s,k in stations[selected_station].stops" 
                v-on:click="push_btn(selected_station,true,k)" 
                :class="s.class">{{s.text}}</button><br/>
        <button v-for="s,k in stations[selected_station].branchs" 
                v-on:click="push_btn(selected_station,false,k)" 
                :class="s.class">{{s.text}}</button><br/>

...(省略)

    <script src="js/main.js"></script>
</body>
</html>

初めはVanillaでやるつもりでしたがたこやき君からVue.jsを薦められました。 触るのは初めてだったものの、わりかし何をしているかは分かりやすかったです。HTML部ではv-forで変数内の各要素を、v-on:clickでイベント発生時の処理を指定します。

js/main.js

const stations = {
    "":{},
    chofu:{
        stops:{
            s1: { text: "1番線進行", status: false, 
                  on_text: "1番線停車", 
                  off_text: "1番線進行", 
                  image: "image/chofu_s1_off.png",
                  on_image: "image/chofu_s1_on.png", 
                  off_image:"image/chofu_s1_off.png", 
                  class:"btn btn-outline-info control-btn" },
            s2: { text: "2番線進行", status: false, 
                  on_text: "2番線停車", 
                  off_text: "2番線進行", 
                  image: "image/chofu_s2_off.png", 
                  on_image: "image/chofu_s2_on.png", 
                  off_image:"image/chofu_s2_off.png", 
                  class:"btn btn-outline-info control-btn" },
            s3: { text: "3番線進行", status: false, 
                  on_text: "3番線停車", 
                  off_text: "3番線進行", 
                  image: "image/chofu_s3_off.png", 
                  on_image: "image/chofu_s3_on.png", 
                  off_image:"image/chofu_s3_off.png", 
                  class:"btn btn-outline-info control-btn" },
            s4: { text: "4番線進行", status: false, 
                  on_text: "4番線停車", 
                  off_text: "4番線進行", 
                  image: "image/chofu_s4_off.png", 
                  on_image: "image/chofu_s4_on.png", 
                  off_image:"image/chofu_s4_off.png", 
                  class:"btn btn-outline-info control-btn" }
        },
        branchs:{
            b1: { text: "2番線入線", status: false, 
                  on_text: "1番線入線", 
                  off_text: "2番線入線", 
                  image: "image/chofu_b1_off.png", 
                  on_image: "image/chofu_b1_on.png", 
                  off_image: "image/chofu_b1_off.png", 
                  class:"btn btn-outline-dark control-btn" },
            b2: { text: "橋本方面", status: false, 
                  on_text: "京王八王子・高尾山口方面", 
                  off_text: "橋本方面", 
                  image: "image/chofu_b2_off.png", 
                  on_image: "image/chofu_b2_on.png", 
                  off_image:"image/chofu_b2_off.png", 
                  class:"btn btn-outline-dark control-btn" },
            b3: { text: "4番線入線", status: false, 
                  on_text: "3番線入線", 
                  off_text: "4番線入線", 
                  image: "image/chofu_b3_off.png", 
                  on_image: "image/chofu_b3_on.png", 
                  off_image:"image/chofu_b3_off.png", 
                  class:"btn btn-outline-dark control-btn" }
        }
    },
    meidaimae:{
        stops:{
            s1: { text: "1番線停車", status: false, 
                  on_text: "1番線停車", 
                  off_text: "1番線進行", 
                  image: "image/meidaimae_s1_off.png", 
                  on_image: "image/meidaimae_s1_on.png", 
                  off_image:"image/meidaimae_s1_off.png", 
                  class:"btn btn-outline-info control-btn" },
            s2: { text: "2番線停車", status: false, 
                  on_text: "2番線停車", 
                  off_text: "2番線進行", 
                  image: "image/meidaimae_s2_off.png", 
                  on_image: "image/meidaimae_s2_on.png", 
                  off_image:"image/meidaimae_s2_off.png", 
                  class:"btn btn-outline-info control-btn" }
        },
        branchs:{
        }
    },
    sasazuka:{
        stops:{
            s1: { text: "1番線進行", status: false, 
                  on_text: "1番線停車", 
                  off_text: "1番線進行", 
                  image: "image/sasazuka_s1_off.png", 
                  on_image: "image/sasazuka_s1_on.png", 
                  off_image: "image/sasazuka_s1_off.png", 
                  class:"btn btn-outline-info control-btn" },
            s2: { text: "2番線進行", status: false, 
                  on_text: "2番線停車", 
                  off_text: "2番線進行", 
                  image: "image/sasazuka_s2_off.png", 
                  on_image: "image/sasazuka_s2_on.png", 
                  off_image: "image/sasazuka_s2_off.png", 
                  class:"btn btn-outline-info control-btn" },
            s4: { text: "4番線進行", status: false, 
                  on_text: "4番線停車", 
                  off_text: "4番線進行", 
                  image: "image/sasazuka_s4_off.png", 
                  on_image: "image/sasazuka_s4_on.png", 
                  off_image: "image/sasazuka_s4_off.png", 
                  class:"btn btn-outline-info control-btn" }
        },
        branchs:{
            b1: { text: "新宿方面", status: false, 
                  on_text: "本八幡方面", 
                  off_text: "新宿方面", 
                  image: "image/sasazuka_b1_off.png", 
                  on_image: "image/sasazuka_b1_on.png", 
                  off_image: "image/sasazuka_b1_off.png", 
                  class:"btn btn-outline-dark control-btn" }
        }
    },
    kitano:{
        stops:{
            s2: { text: "2番線進行", status: false, 
                  on_text: "2番線停車", 
                  off_text: "2番線進行", 
                  image: "image/kitano_s2_off.png", 
                  on_image: "image/kitano_s2_on.png", 
                  off_image: "image/kitano_s2_off.png", 
                  class:"btn btn-outline-info control-btn" },
            s3: { text: "3番線進行", status: false, 
                  on_text: "3番線停車",
                  off_text: "3番線進行", 
                  image: "image/kitano_s3_off.png", 
                  on_image: "image/kitano_s3_on.png", 
                  off_image: "image/kitano_s3_off.png", 
                  class:"btn btn-outline-info control-btn" }
        },
        branchs:{
            b1: { text: "京王八王子方面", status: false, 
                  on_text: "高尾山口方面", 
                  off_text: "京王八王子方面", 
                  image: "image/kitano_b1_off.png", 
                  on_image: "image/kitano_b1_on.png", 
                  off_image: "image/kitano_b1_off.png", 
                  class:"btn btn-outline-dark control-btn" }
        }
    }
}

const vue = new Vue({
    el:'#app',
    data:{
        selected_station:"",
        stations:stations,
        camera_peerids:{},
        theirID:"all",
        is_all:true
    },
    methods:{
        push_btn:async function(station,is_stop,operable_id) {
            console.log(operable_id)
            if (is_stop) {
                stations[station].stops[operable_id].status 
                  = !stations[station].stops[operable_id].status;
                stations[station].stops[operable_id].image 
                  = (stations[station].stops[operable_id].status 
                   ? stations[station].stops[operable_id].on_image 
                   : stations[station].stops[operable_id].off_image);
                stations[station].stops[operable_id].text 
                  = (stations[station].stops[operable_id].status 
                   ? stations[station].stops[operable_id].on_text 
                   : stations[station].stops[operable_id].off_text);
                stations[station].stops[operable_id].class 
                  = (stations[station].stops[operable_id].status 
                   ? "btn btn-info control-btn" 
                   : "btn btn-outline-info control-btn");
                
                await sendOperate(station, 
                                  station + "_" + operable_id, 
                                  "switch", 
                                  (stations[station].stops[operable_id].status 
                                  ? "On" : "Off"));
            } 
            else {
                stations[station].branchs[operable_id].status 
                  = !stations[station].branchs[operable_id].status;
                stations[station].branchs[operable_id].image 
                  = (stations[station].branchs[operable_id].status 
                   ? stations[station].branchs[operable_id].on_image 
                   : stations[station].branchs[operable_id].off_image);
                stations[station].branchs[operable_id].text 
                  = (stations[station].branchs[operable_id].status 
                   ? stations[station].branchs[operable_id].on_text
                   : stations[station].branchs[operable_id].off_text);
                stations[station].branchs[operable_id].class 
                  = (stations[station].branchs[operable_id].status 
                   ? "btn btn-dark control-btn" 
                   : "btn btn-outline-dark control-btn");
                
                await sendOperate(station,
                                  station + "_" + operable_id, 
                                  "switch", 
                                  (stations[station].branchs[operable_id].status 
                                  ? "On" : "Off"));
            }
        }
    },
    
    ...(省略)

});

// functions
function setStationButtonHidden(value) {
    document.getElementById("chofusta").hidden = value;
    document.getElementById("meidaista").hidden = value;
    document.getElementById("kitanosta").hidden = value;
    document.getElementById("sasasta").hidden = value;
    document.getElementById("iwamotosta").hidden = value;
    document.getElementById("kudansta").hidden = value;
    document.getElementById("train").hidden = value;
    document.getElementById("backtokeio").hidden = !value;
    document.getElementById("describe-text").hidden = value;
}

js/api.js

async function GetHttp(url, queries){
    const targetURL = (()=>{
        if(queries){
            const queryStrings = Object.entries(queries).map(([key, value]) => `${key}=${value}`)
            const queryString = queryStrings.reduce((prev,cur)=>`${prev}&${cur}`)
            return `${url}?${queryString}`
        }
        return url
    })()

    console.log(targetURL)

    console.log(url);

    const res = await fetch(targetURL,{})

    const json = await res.json()

    return json



    // const getData = async () => {
    //     try {
    //       /*const response = await fetch(targetURL, { 
    //         mode: 'no-cors'
    //       });*/
    //       xhr = new XMLHttpRequest();
    //       //xhr.setRequestHeader('Content-Type', 'application/json');
    //       xhr.open("GET", targetURL, true);
    //       xhr.send();
    //       const response = xhr.responseText;
    //       xhr.abort();
    //       if (response.status == 200) {
    //         const jsonResponse = JSON.parse(response); //await response.json();
    //         return jsonResponse
    //       }else{
    //         console.log(response)
    //       }
    //       throw new Error('Request done!');
    //     } catch (error) {
    //       console.log(error);
    //     }
    //   }
    // return await getData();
}

...(省略)

async function sendOperate(stationname,operableName,cmd,arg) {
  const url = `${endpoint}/units/${stationname}/${operableName}`
    return await GetHttp(url,{cmd,arg,"token":Token})
}

...(省略)

上のコードではそれぞれの駅のボタンクリック時にステータスやCSSスタイルを切り替える処理を行った後、sendOperate関数からWebAPIを呼び出しています。 操作すると次図のようになります。本来はここにオンオフ状態の同期や使っているユーザーのトークンを発行することでの順番待ち処理を行う予定でしたが、実装が間に合いませんでした。

ボタンで操作した様子

オンオフの切り替えで表示が変化しています。

速度制御部

当日の展示では一台だけ速度制御を行える列車がありました。そのため、こちらではスクロールバーで操作できるようにしました。 初めはスクロールに滑らかに対応させていました*1がさすがに送るデータ量が多すぎるのでスクロール終了時のみにWebAPIの関数を呼び出すようにしました。

index.html

...(省略)

        <p>速度操作可能な列車からの前面展望です!</p>
        <iframe src="https://www.youtube.com/embed/CnVFRJQ6VqY" frameborder="0"
            allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
            allowfullscreen></iframe>
        <p>列車速度:<output id="speedvalue">49</output> <input type="range" name="speed" id="speed" min="-50" max="49" step="1" value="49" onchange="displaySpeed(this)"></p>

...(省略)

js/main.js

...(省略)

async function displaySpeed(obj) {
    document.getElementById('speedvalue').value = obj.value;
    await sendOperate("train", "train", "speed", parseInt(obj.value) + 50);
}

WebRTC (Skyway) による配信

ユーザーが遠隔で制御できているか確認をするためには中継する映像が必要になります。 初めはYouTubeライブなどで良いかな、と考えていましたが少し遅延があるためリアルタイムで操作している感覚を出すためにも低遅延な配信が必要になりました。

困っていたところ、たこやき君からSkyWayというWebRTC(Web Real Time Communication)のAPIがあることを教えてもらい、組み込んでみることにしました。 とりあえずチュートリアル通りに実装しテストしてみたところ、自分の環境では1秒未満の遅延(ほぼ0に近い結果)となり、これは早い!ということで採用しました。

SkyWayではPeerIDと呼ばれる電話番号にあたるものを取得することで通話をすることができます。 とはいえ、今回は配信が目的であり双方向に受信するつもりはないのでサーバー上に用意したPeerIDのリストを取得し、それを各ユーザーのブラウザで発信処理を行うことで実装しました。

js/main.js

let localStream;

// make peer
const peer = new Peer({
    key: <API key>,
    debug: 3
});

// get peer id
peer.on('open', () => {
    // get server id here
    const theirID = /* server id */ peer.id;
    const mediaConnection = peer.call(theirID, localStream);
    setEventListener(mediaConnection);
});

// 発信処理
function makeCall(theirID) {
    const mediaConnection = peer.call(theirID, localStream);
    setEventListener(mediaConnection);
};

// イベントリスナを設置する関数
const setEventListener = mediaConnection => {
    mediaConnection.on('stream', stream => {
        // video要素にカメラ映像をセットして再生
        const videoElm = document.getElementById('video')
        videoElm.srcObject = stream;
        videoElm.play();
    });
}

// 着信処理
peer.on('call', mediaConnection => {
    mediaConnection.answer(localStream);
    setEventListener(mediaConnection);
});

peer.on('error', err => {
    alert(err.message);
});

peer.on('close', () => {
    alert('Connection closed.')
})

...(省略)

const vue = new Vue({

    ...(省略)

    mounted:function(){
        const url = 'https://us-central1-koken-key.cloudfunctions.net/referPeerid';

        fetch(url)
        .then(function (data) {
            return data.json(); // 読み込むデータをJSONに設定
        })
        .then(function (json) {
            this.camera_peerids = json
        });

        loadOperators();
    },
    watch:{
        selected_station:function(selected_station){
            makeCall(camera_peerids[selected_station].peer_id)
        }
    }
});

...(省略)

駅を選択するとサーバーからPeerIDを取得し、そのIDに対して発信処理を行うことで映像を取得できます。

小ネタ

  • 操作部の枠は京王カラーの二重枠線です。疑似要素のafterを用います。

main.css

#imagebox {
    padding-top: 17.1%;
    padding-bottom: 17.1%;
    border: 5px ridge #dd0076;
    background-color: white;
}

#imagebox::after {
    content: "";
    border: 5px ridge #000088;
    position: absolute;
    top: 5px;
    left: 5px;
    width: calc(100% - 10px);
    height: calc(100% - 10px);
}
  • ツイートしたときにサムネイルが出たりするとよさそう、みたいな意見を後輩からもらったので実装してみました。OGP(Open Graph Protocol)と呼ばれるものです。上の特設サイトへのリンクのように説明文やサムネイル画像が表示されています。

改善点とか反省とか

自動制御パートは完成していましたが遠隔操作部で引っかかってしまい初日は間に合わせられませんでした。 当日のYouTubeライブはやっていたのでデスマ中の部員が配信される事態に、、本当にすみませんでしたm(_ _)m

個人的なUIの設計についてだとやはり少し味気なさを感じるデザインだったかな、とは思いました。 ただあれ以上装飾したりしてもかえって見づらくなって微妙そうなので正直悩ましい感じでした。 それよりは操作の同期等はもちろん、速度調整バーを大きくしたり操作部の全景画像を見やすくしたりすべきといった改善点があったので、次にこのような機会があればよりフレンドリーなUIを設計出来るようになりたいと思いました。

おわりに

こうやって記事書いているとバーチャルでもそれを生かした展示ができたのですごく楽しかったなあって思い出してます。 ただやっぱり次回はいつもの調布祭がいいですね

明日はpof君のWebAPIまわりのお話になります。正直私とは桁違いにつよつよでWebUIのも半分くらいは彼(とたこやき君)のおかげだったりするので期待したいですね。

*1:リアルタイムの処理にする場合onchangeではなくoninputを用います