WEBアプリの作り方 ~ルーレット~


WEBブラウザの進化により、ブラウザ上で動かせるアプリが増えてきました。ここでは、そのようなWEBアプリはどうやって作ればいいのか?について解説していきます。

スポンサーリンク

どんなものが作れる?

完成したものはルーレット WEBツールにて公開しています。
最新のソースコードはGitHubにて公開しています。
ぜひご覧ください。

(修正が加えられている場合がございます。ご了承ください。)

使用するもの

まず、WEBアプリの動作環境と開発環境を指定しておきます。

ブラウザ

現時点で世界シェアが最も高いGoogle Chrome向けに作成しますので、コンピュータにGoogle Chromeをインストールしておいてください。ただ、よほど特殊なことを行わなければ、他のブラウザでも大体同じような動作をします。

エディター

私はいつもVisual Studio Codeを使用していますので、これを使用していきます。もちろん、AtomVimEmacsなどでも構いません。1つ言えることは、絶対にメモ帳はやめた方がいいです。コードのハイライトがわかりづらくなったり、インデント(字下げ)が見にくかったり、エラーを見つけてくれなかったりして、開発効率が下がってしまいます。

JavaScriptライブラリ

jQueryというJavaScriptライブラリを使用します。これにより、HTMLの中の要素を短い記述で取得できるようになります。

描画ライブラリ

難しいことを考えずパパっとアプリを作りたいので、p5.jsという描画ライブラリを使用します。p5.jsは難しいものではありません。ちょっとコードを書くだけで自分の思い通りの図形を描くことができる、非常にお手軽かつ高機能な描画ライブラリです。JavaScriptと連携して動作させることが可能なので、WEBフォームからの入力をJavaScriptで取ってきて、p5.jsの中で使用するようなことが簡単にできます。

テンプレート

WEBアプリを作成するためのテンプレートを作成したので、GitHubからダウンロードします。
GitHubからダウンロード

"Download ZIP"を押すとダウンロードされます。

編集すべきファイルはapp.jsとindex.htmlの2つです。

テンプレートの中には既にjQueryとp5.jsを使用する準備を行うコードが入っています。

HTMLで入力フォーム等を作成

早速開発していきます。まずはindex.htmlを編集して、フォームを作成します。

ルーレットの真下に、ルーレットを操作するためのボタン群を設置します。リセット、スタート、ストップボタンがあれば良いでしょう。

...
<div id="canvas"></div>
<button type="button" id="reset" onclick="reset()">リセット</button>
<button type="button" id="start" onclick="start()">スタート!</button>
<button type="button" id="stop" onclick="stop()" style="display:none;">ストップ!</button>

結果を表示するための領域も作っておきます。結果は大きな文字で表示したいので、スタイルも変更します。<style></style>の中の内容は、CSSファイルを作成してその中に入力しても問題ありません。その場合、<head></head>の中にlinkタグを挿入してCSSを読み込むことを忘れないでください。

<h2>結果</h2>
<p id="result">????</p>
<style>
#result{
    font-size: 40px;
    font-weight: bold;
}
</style>

次に、ルーレットの設定を行う部分を作成します。
今回はn個の項目の確率が等しいルーレット以外に、確率が2:1:1などの、当選割合が異なるルーレットも作れるようにしたいと思います。
1つの項目につき

  • ルーレット内のどの色か
  • 項目名
  • 割合
  • 確率(自動計算される)
  • 削除ボタン

が必要になりそうです。
また、項目を追加するボタンも必要です。

項目の色を表示するにはp5.jsとの連携が必要となるので、とりあえず黒色の正方形■を表示しておきます。1辺10pxの正方形として、色はbackground-colorで指定します。

<div>
    <h2>ルーレット設定</h2>
    <div>
        <h3>項目名と割合</h3>
        <button type="button" class="add">追加</button>
        <table id="table">
            <tr><th>色</th><th>項目名</th><th>割合</th><th>確率</th><th>削除</th></tr>
            <tr class="item"><td><div class="color-indicator" style="background-color:#000000;"></div></td><td><input type="text" class="name" value="項目A"></td><td><input type="number" class="ratio" value="1"></td><td class="probability"></td><td><button type="button" onclick="rmItem(this)">削除</button></td></tr>
            <tr class="item"><td><div class="color-indicator" style="background-color:#000000;"></div></td><td><input type="text" class="name" value="項目B"></td><td><input type="number" class="ratio" value="1"></td><td class="probability"></td><td><button type="button" onclick="rmItem(this)">削除</button></td></tr>
            <tr class="item"><td><div class="color-indicator" style="background-color:#000000;"></div></td><td><input type="text" class="name" value="項目C"></td><td><input type="number" class="ratio" value="1"></td><td class="probability"></td><td><button type="button" onclick="rmItem(this)">削除</button></td></tr>
            <tr class="item"><td><div class="color-indicator" style="background-color:#000000;"></div></td><td><input type="text" class="name" value="項目D"></td><td><input type="number" class="ratio" value="1"></td><td class="probability"></td><td><button type="button" onclick="rmItem(this)">削除</button></td></tr>
            <tr class="item"><td><div class="color-indicator" style="background-color:#000000;"></div></td><td><input type="text" class="name" value="項目E"></td><td><input type="number" class="ratio" value="1"></td><td class="probability"></td><td><button type="button" onclick="rmItem(this)">削除</button></td></tr>
            <tr class="item"><td><div class="color-indicator" style="background-color:#000000;"></div></td><td><input type="text" class="name" value="項目F"></td><td><input type="number" class="ratio" value="1"></td><td class="probability"></td><td><button type="button" onclick="rmItem(this)">削除</button></td></tr>
        </table>
        <button type="button" class="add">追加</button>
    </div>
</div>
<style>
.color-indicator{
    width: 10px;
    height: 10px;
}
</style>

次に、確率を自動計算する関数を作成します。
jQueryで各項目の割合を全て取得して足し合わせたものを用意します。その値で各項目の割合を割った値に100をかけると、パーセント表示になります。小数点以下3桁まで表示することにします。

<script>
function recalculate(){
    var ratioSumJs = 0;
    $('.ratio').each(function(){
        ratioSumJs += $(this).val()-0;
    });
    $(".item").each(function(){
        var probability = ($(this).find(".ratio").first().val()-0) / ratioSumJs;
        probability*=100;
        probability = probability.toFixed(3);
        $(this).children(".probability").first().html(probability+"%");
    });
}
</script>

JSでは「文字列-0」を行うと、文字列を数値に変換してくれます。簡潔に書けて面白いので、覚えておくと良いかもしれません。

次に、項目の追加ボタンの機能を作成します。
これは単純に、上記でtableの中に入れていた行と同じものを新たにtableの最後に追加するだけです。
ついでに追加した項目も含めて、確率を自動的に再計算させましょう。

<script>
$('.add').click(function(){
    var add = '<tr class="item"><td><div class="color-indicator" style="background-color:#000000;"></div></td><td><input type="text" class="name" value="項目"></td><td><input type="number" class="ratio" value="1"></td><td class="probability"></td><td><button type="button" onclick="rmItem(this)">削除</button></td></tr>';
    $('#table').append(add);
    recalculate();
});
</script>

次に、項目の削除ボタンの機能を作成します。
押された削除ボタンの親の親がその行の<tr>要素となりますので、それを削除します。
また、その行の要素を除いて、確率を再計算させます。

<script>
function rmItem(e){
    if($('.ratio').length>2){
        $(e).parent().parent().remove();
        recalculate();
    }
}
</script>

最後に、当選割合の変更を検知して、確率を自動計算させるようにします。

<script>
$('#table').change(function(){
    recalculate();
});
</script>

ここで問題が発生します。
基本的にはclassが"ratio"な要素の変更を検知してやればいいのですが、追加ボタンを押して項目を追加した場合、この書き方ではページロード時に存在していなかった要素にはイベントが設定されません
それを回避するため、ページロード時から必ず存在している要素に対してイベントハンドラを設定し、そこを経由して変更を検知してやる必要があります。
ここでは、ページロード時から存在している親要素である#tableに対してイベントハンドラを設定し、その中の子孫要素である.ratioの変更を監視します。

<script>
$('#table').on('change', '.ratio', function(){
    recalculate();
});
</script>

このような書き方で実現可能です。

以上でフォームが完成となります。

p5.jsでアプリを書いていく

ここからはapp.js内に記述していきます。

ルーレットはどのような流れで動作するのでしょうか。考えてみると、

  1. スタートボタンが押されるまで待機
  2. スタートボタンが押されて加速
  3. 最大速度となり定速に
  4. ストップボタンが押されて減速
  5. 停止・結果表示

となります。ストップボタンは加速中でも押せるようにしてもいいかもしれません。
以上5つの状態を列挙型(enum)で定義しておきます。また、現在の状態を保持する変数も設けます。状態の初期値はwaitingとしておきます。

var Mode = {
    waiting: 0,
    acceleration: 1,
    constant: 2,
    deceleration: 3,
    result: 4
};
var mode = Mode.waiting;

ルーレットを回している最中に、ルーレットの設定が変更されるかもしれません。それに備えて、フォームの内容を変数に保存しておきます。
1つの項目につき

  • 項目名
  • 確率

を持っておけばいいので、それぞれを保持する空の配列を作成します。

var nameList = [];
var probabilityList = [];
var colorList = [];

とりあえず必要になりそうな変数はこのようになります。
それでは、ロジックを書いていきましょう。

基本設定

まず、描画領域(canvas)のサイズを設定します。テンプレートの中に
createCanvas(600,300)
という記述がありますが、少し高さが足りないので、300を400に変更します。

テンプレート内のdraw関数は、毎フレーム呼ばれます。以下、この中に描画処理を書いていきます。

毎フレーム、背景を白色にしたいので、fillで塗りつぶし色を指定して、rectで実際に描画します。width(幅)やheight(高さ)変数には、自動的にcanvasのサイズが入ります。今回はwidthには600、heightには400が自動的に代入されます。

次にルーレットの中心を設定します。今回は単純にcanvasの中心とするのでx座標はwidth/2、y座標はheight/2だけ移動します。

function draw(){
    fill(255,255,255);
    rect(0,0,width,height);
    translate(width/2, height/2);
    //...
}

このコード以降、原点が(width/2, height/2)に移動します。つまり、例えば(10, 20)に点を打つというコードを書くと、自動的に(width/2 + 10, height/2 + 20)という座標に点が打たれるということです。

フォームデータの取得

先ほど用意した、フォームデータ保存用配列に対して代入処理を行っていきます。

その前に、入力された値が妥当かどうか検証するvalidation関数を作っておきましょう。
検証内容としては、「項目名が空欄ではない」、「割合が0より大きい」の2つとします。

function validation(){
    var badflag = false;
    $('.name').each(function(){
        if($(this).val()==""){
            badflag = true;
        }
    });
    $('.ratio').each(function(){
        if(!($(this).val()>0)){
            badflag = true;
        }
    });
    if(badflag){
        alert('項目名と割合を正しく設定してください。');
        return 1;
    }
    return 0;
}

validationが書けたので、次は実際にフォーム内容を取得していきます。
まず、各項目の確率計算はフォーム作成の部分で既に作成しています。
同じような処理なので説明は割愛します。
それに加えて、項目名も同様に取得していきます。

function dataFetch(){
    var ratioSum = 0.0;
    $('.item').each(function(){
        var ratio = $(this).find('.ratio').val()-0;
        ratioSum += ratio;
    });
    nameList = [];
    probabilityList = [];
    $('.item').each(function(){
        var name = $(this).find('.name').val();
        var ratio = $(this).find('.ratio').val()-0;
        nameList.push(name);
        probabilityList.push(ratio/ratioSum);
    });
    //...
}

難しいのはここからです。各項目に対してルーレットで割り当てられる色を設定するのですが、見やすくするにはどのような配色を行えば良いか考えてみます。

基本的に人間の目は、同じような色が隣り合うと見にくいと思います。そのため、できるだけ色相環で遠い色が隣に来るようにしていきます。つまり補色に近い色が隣にあると見やすいというわけです。
色相を扱うためRGBではなく、ここではHSL色空間を使用して、Hue(色相)、Saturation(彩度)、Lightness(輝度)をうまく設定していくこととします。

彩度と輝度はとりあえず置いておいて、色相の値を並べていきます。
方針としては、0~255の色相を項目数でn等分します。そして、その中から
0番目、n/2番目、1番目、n/2+1番目、… と順番に値を取得し、並べ替えていきます。

n=6の場合、

00

1127

242

3170

485

5212

という値となります。各項目の色相がうまく離れていることがわかります。

nが奇数の場合もうまい具合にプログラムしておきます。
COLOR_ADJという定数も追加します。

//datafetch関数の外
const COLOR_ADJ = 0.4;

//datafetch関数の中
//...
var colors = [];
len = nameList.length;
for(var i=0;i<len;i++){
    colors.push(Math.floor(255/len*i));
}
colorList = [];
if(len%2==0){
    for(var i=0;i<len;i+=2){
        colorList[i] = colors[Math.floor(i/2)];
    }
    for(var i=1;i<len;i+=2){
        colorList[i] = colors[Math.floor(i/2 + len/2)];
    }
}else{
    for(var i=0;i<len;i+=2){
        colorList[i] = colors[Math.floor(i/2)];
    }
    for(var i=1;i<len;i+=2){
        colorList[i] = colors[Math.floor(i/2)+Math.floor(len/2)+1];
    }
}
cssColorSet();//後述

ここで、フォームの色表示部分に色を反映させたいと思います。
色の計算式を決めないといけないので、以下のように決めました。

H=上記の色相
S=255-とある定数*上記の色相
L=128

Lは128で純色となります。Sは255で純色、0で灰色になります。Sをなぜこのような式にしたかについては、やってみるとわかりますが、赤色と紫色の区別がつきにくかったためです。というのも、色相が255までいくと、色相環を一周してしまい、ほぼ同じ色となってしまいます。そこで、このように設定して差をつけました。

ここまで設計した上で、実際にCSSに色を適用していきます。CSSに対しては、なじみ深い(?)RGBで設定していきます。

function cssColorSet(){
    var counter = 0;
    $('.color-indicator').each(function(){
        push();
        colorMode(HSL, 255);
        var c = color(colorList[counter],255-COLOR_ADJ*colorList[counter],128);
        pop();
        $(this).css('background-color', "rgb("+c._getRed()+","+c._getGreen()+","+c._getBlue()+")");
        counter++;
    });
}

cssColorSet関数を呼び出すと、色の配列から自動的にフォーム内の色表示部分に色が表示されるようになりました。

ルーレットの描画

求めた確率に従って、各項目の面積割合を設定していきます。
全項目合わせて1周(2π)するようにします。
arc関数で円弧を描画できます。
始点・終点の角度をうまく計算してやる必要があります。
n番目の円弧を描くときは、0~n-1番目の円弧の角度の和を始点として、2π*(n番目の項目の当選確率)だけ進んだところを終点とすれば良いでしょう。
半径は定数で設定しておきます。
ここで、push関数、pop関数を使用しています。push関数で現在の描画に関する情報をいろいろ保存してくれます。pop関数を呼び出すと、それらを復元してくれます。カラーモード(colorMode)だったり、塗りつぶし色(fill)だったりを保存・復元するために使用しています。

const RADIUS = 100;
function drawRoulette(){
    var angleSum = 0.0;
    push();
    colorMode(HSL, 255);
    for(var i=0;i<len;i++){
        fill(colorList[i],255-COLOR_ADJ*colorList[i],128);
        arc(0,0,RADIUS*2,RADIUS*2,angleSum,angleSum+2*PI*probabilityList[i]);
        angleSum += probabilityList[i]*2*PI;
    }
    pop();
}

ルーレットの回転はここでは考えていません。以下の場合分けの時に座標を丸ごと回転させた後にルーレットを描画することで、ルーレットが回転しているように見せるようにします。

状態での場合分け

switch文により、現在の状態で場合分けを行います。

function draw(){
    fill(255,255,255);
    rect(0,0,width,height);
    translate(width/2, height/2);

    switch(mode){
    case Mode.waiting:
        break;
    case Mode.acceleration:
        break;
    case Mode.constant:
        break;
    case Mode.deceleration:
        break;
    case Mode.result:
        break;
    }
}

それぞれどのような処理を行えばいいか考えていきます。

・waiting(待ち状態)
ルーレットを回転させずそのまま描画

・acceleration(加速中)
ルーレットを、ある加速度で加速させる
ルーレットを描画
ある速さ以上になったら、定速状態へ移行

・constant(定速)
毎フレーム同じだけルーレットの角度が増加する
ルーレットを描画

・deceleration(減速)
ルーレットを、ある加速度で減速させる
ルーレットを描画
速さが0になったら結果表示状態へ移行

・result(結果)
ルーレットを停止
ルーレットを描画
結果を取得してHTML側に表示させる

どの状態でもルーレットを描画するのは変わらないので、ルーレット描画関数はswitch文の外に出しても良いことになります。mode=Mode.resultの場合の記述の後にdrawRoulette関数を置くことにしましょう。

次に、必要になる定数を指定したり、現在の様々な状態を持っておく変数を用意しておきます。

//draw関数外
const ACCEL = 0.01; //加速時の加速度
const DECEL = 0.01; //減速時の加速度
const MAX_SPEED = 1.0; //最大速度
const DECEL_RAND_LEVEL = 10; //減速の乱数の幅を設定
const DECEL_RAND_MAGNITUDE = 0.001; //減速の乱数の影響力を設定

var speed = 0.0;
var theta = 0.0;
var len = 0;
var resultDisplayed = false;

それでは実装していきます。

・mode = Mode.accelerationのとき

rotate関数により、ルーレットをtheta[radian]だけ回転させます。
物理的な等角加速度運動を考えて、speedとthetaを変化させていきます。
thetaが2πを超えたら、0<=theta<2π となるようにthetaの値をいじります。

case Mode.acceleration:
    if(speed<MAX_SPEED){
        speed += ACCEL;
    }else{
        mode = Mode.constant;
        speed = MAX_SPEED;
    }
    theta += speed;
    theta-=(Math.floor(theta/2/PI))*2*PI;
    rotate(theta);
    break;

・mode = Mode.constantのとき

speedは一定です。

case Mode.constant:
    theta += speed;
    theta-=(Math.floor(theta/2/PI))*2*PI;
    rotate(theta);
    break;

・mode = Mode.decelerationのとき

accelerationの逆です。
ただし、ここで一工夫できます。毎回同じ加速度で減速すると、スタートボタンとストップボタンを同じように押せばルーレットが毎回同じところで停止することになってしまいます。ランダム性をもたせるため、値をランダムに変動させましょう。ランダムな整数を取得する関数は既にテンプレートに入っているので、これを利用します。

case Mode.deceleration:
    if(speed>DECEL){
        speed -= DECEL+getRandomInt(-DECEL_RAND_LEVEL,DECEL_RAND_LEVEL)*DECEL_RAND_MAGNITUDE;
    }else{
        speed = 0.0;
        mode = Mode.result;
    }
    theta += speed;
    theta-=(Math.floor(theta/2/PI))*2*PI;
    rotate(theta);
    break;

・mode = Mode.resultのとき

ルーレットはthetaだけ回転した状態で止まっています。
モードがここに入った最初のフレームだけ、結果を取得してHTML側に反映させることにします。
結果の取得は少し難しい式です。
まず前提として、ルーレットの矢印はルーレットの真上にあるものとします。
p5.jsでは、角度が0というのは水平右向き(→)、角度の正方向は時計回り(⤵)となっているので、真上(↑)は270°、つまり3π/2[radian]となります。
n番目の項目の領域が真上にあるかどうかを1つ1つ判定していきます。つまり、n番目の領域が3π/2をまたいでいるかどうかを判定します。
ここで注意が必要なのは、ルーレットに乗った座標系(rotate後)での0°が、静止系(rotate前)での270°~360°の中に入っている場合です。この場合はn番目の領域が3π/2 + 2π = 7π/2をまたいでいるかどうか判定しなければなりません。
以上に注意して判定していきます。
項目名はnameListに入っているので、result番目の項目が当選したとわかった場合、nameList[result]で項目名を取り出すことができます。
その値をjQueryでHTML側に表示させます。

case Mode.result:
    rotate(theta);
    if(!resultDisplayed){
        resultDisplayed = true;
        var angleSum = theta;
        var beforeAngleSum = theta;
        var result = 0;
        for(var i=0;i<len;i++){
            angleSum += probabilityList[i]*2*PI;
            if((angleSum>3/2*PI&&beforeAngleSum<3/2*PI) || (angleSum>7/2*PI&&beforeAngleSum<7/2*PI)){
                result = i;
                break;
            }
            beforeAngleSum = angleSum;
        }
        $('#result').html(nameList[result]);
    }
    break;
}//switchがここで終わる
drawRoulette();//ルーレット描画関数はswitchの外に出す

ルーレットの矢印の描画

矢印はルーレットと一緒に回らないように、rotate関数を呼ぶ前に描画します。
triangle関数で赤色の三角形を描画します。
ルーレットの中心から、ルーレットの半径+マージンだけ上に配置します。
三角形のサイズとマージンは定数で設定します。

const TRIANGLE_SIZE = 10;
const MARGIN = 10;

function draw(){
    fill(255,255,255);
    rect(0,0,width,height);
    translate(width/2, height/2);

    fill(255,0,0);
    push();
    translate(0, -RADIUS-MARGIN);
    triangle(0, 0, -TRIANGLE_SIZE/2, -TRIANGLE_SIZE, TRIANGLE_SIZE/2, -TRIANGLE_SIZE);
    pop();

    switch(mode){

リセット・スタート・ストップボタンの実装

スタートボタンはmodeがwaitingの時に押されると、スタートボタンを消してストップボタンを出現させたり、フォーム情報を取得したり、modeをaccelerationに変更したりします。

function start(){
    if(mode==Mode.waiting){
        if(validation()==1){
            return;
        }
        $('#stop').css('display', 'inline-block');
        $('#start').css('display', 'none');
        dataFetch();
        mode = Mode.acceleration;
    }
}

ストップボタンはmodeがconstantの時に押されると、ストップボタンを消してmodeをdecelerationに変更します。

function stop(){
    if(//mode==Mode.acceleration || //加速中でもストップボタンを効かせるにはコメントアウトを解除
       mode==Mode.constant){
        $('#start').css('display', 'none');
        $('#stop').css('display', 'none');
        mode = Mode.deceleration;
    }
}

リセットボタンはスピードや角度を0にしたり、結果表示を初期化したり、スタートボタンを表示させたりします。

function reset(){
    $('#start').css('display', 'inline-block');
    $('#stop').css('display', 'none');
    theta = 0.0;
    speed = 0.0;
    mode = Mode.waiting;
    if(validation()==0){
        dataFetch();
    }
    $('#result').html('????');
    resultDisplayed = false;
}

それぞれ、フォームの入力内容の検証が必要な部分には、validation関数を入れて、その返り値が0かどうかを確認します。

ページが初めてロードされたときも、フォームの初期入力が自動的にルーレットに入るようにしておきます。同時に、パーセンテージも計算させます。
ルーレットの初期化にはp5.jsが関わってくるので、HTML側に初期化処理を書くのではなく、p5.jsが準備完了となったのを保証されたタイミングで初期化させます。そのためには、app.js内のsetup関数内に処理を記述します。

function setup(){
    var canvas = createCanvas(600,400);
    canvas.parent('canvas');
    textSize(20);
    stroke(0,0,0);
    fill(0,0,0);
    background(255,255,255);
    recalculate();
    dataFetch();
}

HTMLファイルの修正

項目を追加・削除・変更したとき、かつmodeがwaitingのとき、フォームの入力内容を即時ルーレット側に反映させるようにします。
以下のコードを、$('.add').click、function rmItem(e)、$('#table').on それぞれのrecalculate()の後に入れます。

if(mode==Mode.waiting){
    dataFetch();
}

動かしてみる

エディタ上でエラーが出ていないことを確認して、ブラウザ上で実際に動かしてみます。index.htmlを右クリック、プログラムから開く、Google Chromeを選択します。

このような画面が出れば成功です!
実際に回してみると…

このように結果が表示されました。

このバージョンではHTMLは特に装飾を施していません。ご自身でCSSをいじってみるのも良いかもしれません。

スポンサーリンク

最後に

最近増えてきている「WEBアプリ」というものの作り方が少しでも伝わったようでしたら幸いです。p5.jsを使用することで、JavaScriptで生のCanvasを操作することなく簡単にアプリケーションを作成できますので、ルーレット以外のアプリづくりに応用していただけますと嬉しいです。

ここで作成したルーレットは、
ルーレット WEBツール
こちらにて実際にWEBアプリとして公開しています。
Bootstrapによる装飾も加えておりますので、ぜひご参考にしてください。

また、最新のソースコードはGitHubにて公開しています。更新があった場合はGitHubにコミットされます。

最後までご覧いただきありがとうございました。

参考文献

p5.jsのリファレンス

last

フォローする