1. はじめに
【一文で要約】
細かい指定をできるタイムキーパーのシステムを、さくっとつくりたかった。
【経緯】
パーラメンタリーの英語ディベートにおいては、どの形式かにもよりますがだいたいは7分間のスピーチをします。ジャッジは1分・6分・7分・7分15秒のときにベルを鳴らしたり手を叩いて経過時間を知らせねばならず、しかも1・6分の時は一回、7分の時は二回、7分15秒の時は三回音を鳴らさねばなりません。スピーチ時間が7分のものもあれば4分のみのこともあり、なかなかバラけています。
このように、ベルを鳴らしたいタイミングや鳴らすときの回数も細かく分かれているので、これに対応したタイムキーパーのアプリを見つけることができませんでした。
ないなら作ってみようと思い、意外と要件を満たせるものが短い時間で仕上げられたので、忘備録がてらまとめておこうと思います。
上は、今回作ったサイトです。
ディベーター以外にここまで細かくタイムキーパーを必要とする人もなかなかいないとは思いますが、何かで必要になったらぜひ使ってください。
2. ひさびさにPlainなJavaScriptを使った
最初はNext.jsを使おうとも思いましたが、そこまで大掛かりなシステムではないし、自分や身内だけで使うことを想定していてUIにこだわる必要もなかったので、さくっと、動作が速い(と思われる)生のJavaScriptでコーディングするようにしました。
システム自体はJavaScriptのみで貫徹できたものの、若干処理が高度になるほどに、宣言的にUIや処理を構築できるReactのありがたさに気づかされることもありました……。
まず、HTMLファイルを以下に示します。
<!DOCTYPE html>
<html lang="ja">
<head>
<title>ベルタイマー</title>
</head>
<body>
<h1>ベルタイマー</h1>
<section id="timer-container" >
<section class="timer-section" id="time-1">
<div>
<label for="min-input-1">時間1</label>
<br />
<label for="min-input-1">分:</label>
<input type="number" min="0" id="min-input-1" placeholder="minutes">
<br />
<label for="sec-input-1">秒:</label>
<input type="number" min="0" id="sec-input-1" placeholder="second">
</div>
<div>
<label for="count-input-1">ベルの回数:</label>
<input type="number" min="0" max="5" id="count-input-1">
</div>
</section>
<section class="timer-section" id="time-2">
<div>
<label for="min-input-2">時間2</label>
<br />
<label for="min-input-2">分:</label>
<input type="number" min="0" id="min-input-2" placeholder="minutes">
<br />
<label for="sec-input-2">秒:</label>
<input type="number" min="0" id="sec-input-2" placeholder="second">
</div>
<div>
<label for="count-input-2">ベルの回数:</label>
<input type="number" min="0" max="5" id="count-input-2">
</div>
</section>
<section class="timer-section" id="time-3">
<div>
<label for="min-input-3">時間3</label>
<br />
<label for="min-input-3">分:</label>
<input type="number" min="0" id="min-input-3" placeholder="minutes">
<br />
<label for="sec-input-3">秒:</label>
<input type="number" min="0" id="sec-input-3" placeholder="second">
</div>
<div>
<label for="count-input-3">ベルの回数:</label>
<input type="number" min="0" max="5" id="count-input-3">
</div>
</section>
<section class="timer-section" id="time-4">
<div>
<label for="min-input-4">時間4</label>
<br />
<label for="min-input-4">分:</label>
<input type="number" min="0" id="min-input-4" placeholder="minutes">
<br />
<label for="sec-input-4">秒:</label>
<input type="number" min="0" id="sec-input-4" placeholder="second">
</div>
<div>
<label for="count-input-4">ベルの回数:</label>
<input type="number" min="0" max="5" id="count-input-4">
</div>
</section>
</section>
<div>
<button id="more">増やす</button>
<button id="less">減らす</button>
</div>
<button id="start-btn">スタート</button>
<button id="stop-btn">停止</button>
<button id="reset-btn">リセット</button>
<h2 id="timer">0秒</h2>
<audio id="bell-sound" src="bell.mp3"></audio>
<script src="script.js"></script>
</body>
</html>
UIは画像の通りです。
ボタンの大きさを整えたり要素を中央に寄せたり、CSSで少しばかり装飾しました。
とはいえ、紹介するほどのこともしていないので、コードの掲載は省略します。
JavaScriptのコードは以下の通りです。
今回はタイマーの設定を増やしたり減らしたりする処理も書いているので、HTMLの要素の増減も実装しています。
let timer;
let currentTime = 0;
let isRunning = false;
const timerContainer = document.getElementById("timer-container");
const timerDisplay = document.getElementById('timer');
const bellSound = new Audio("bell.mp3");
const startBtn = document.getElementById('start-btn');
const stopBtn = document.getElementById('stop-btn');
const resetBtn = document.getElementById('reset-btn');
const moreBtn = document.getElementById("more");
const lessBtn = document.getElementById("less");
startBtn.addEventListener('click', startTimer);
stopBtn.addEventListener('click', stopTimer);
resetBtn.addEventListener('click', resetTimer);
moreBtn.addEventListener("click",moreTimer);
lessBtn.addEventListener("click",lessTimer);
function startTimer() {
if (isRunning) return;
const timerSectionsLength = getTimerSections().timerSectionsLength;
const timeList = new Array(timerSectionsLength).fill(0).map((_,index) => {
const minutes = validateInput(document.getElementById(`min-input-${index+1}`).value);
const seconds = validateInput(document.getElementById(`sec-input-${index+1}`).value);
return minutes*60 + seconds;
});
const countList = new Array(timerSectionsLength).fill(0).map((_, index) => {
return validateInput(document.getElementById(`count-input-${index+1}`).value) || 1;
});
if(!timeList[0]) return;
isRunning = true;
currentTime = 0;
timer = setInterval(async() => {
currentTime++;
timerDisplay.textContent = currentTime <= 60 ? `${currentTime}秒` : `${Math.floor(currentTime / 60)}分${currentTime % 60}秒` ;
const index = timeList.indexOf(currentTime);
if(index !== -1){
await ringBell(countList[index]);
}
}, 1000);
}
function stopTimer() {
clearInterval(timer);
isRunning = false;
}
function resetTimer() {
stopTimer();
currentTime = 0;
timerDisplay.textContent = '0秒';
}
function moreTimer() {
function createLabel(forAttr, text) {
const label = document.createElement('label');
label.setAttribute('for', forAttr);
label.textContent = text;
return label;
}
function createInput(id, type, min, placeholder) {
const input = document.createElement('input');
input.setAttribute('id', id);
input.setAttribute('type', type);
input.setAttribute('min', min);
if (placeholder) {
input.setAttribute('placeholder', placeholder);
}
return input;
}
const nextSectionIndex = getTimerSections().timerSectionsLength + 1;
const section = document.createElement('section');
section.id = `time-${nextSectionIndex}`;
section.className = "timer-section";
const div1 = document.createElement('div');
div1.appendChild(createLabel(`min-input-${nextSectionIndex}`, `時間${nextSectionIndex}`));
div1.appendChild(document.createElement('br'));
div1.appendChild(createLabel(`min-input-${nextSectionIndex}`, '分:'));
div1.appendChild(createInput(`min-input-${nextSectionIndex}`, 'number', '0', 'minutes'));
div1.appendChild(document.createElement('br'));
div1.appendChild(createLabel(`sec-input-${nextSectionIndex}`, '秒:'));
div1.appendChild(createInput(`sec-input-${nextSectionIndex}`, 'number', '0', 'seconds'));
const div2 = document.createElement('div');
div2.appendChild(createLabel(`count-input-${nextSectionIndex}`, 'ベルの回数:'));
div2.appendChild(createInput(`count-input-${nextSectionIndex}`, 'number', '0'));
div2.lastChild.setAttribute('max', '5');
section.appendChild(div1);
section.appendChild(div2);
timerContainer.appendChild(section);
}
function lessTimer(){
const {timerSections, timerSectionsLength }= getTimerSections();
if(timerSectionsLength <= 1) return;
const lastTimerSection = timerSections[timerSectionsLength - 1];
lastTimerSection.remove();
}
async function ringBell(count) {
let waitTime = count == 1 ? 2000 : count == 2 ? 900 : 550;
for(let i = 0; i < count; i ++){
if(i === count - 1) waitTime = 2000;
bellSound.play();
await new Promise(resolve => setTimeout(resolve,waitTime));
bellSound.pause();
bellSound.currentTime = 0;
}
}
function validateInput(value) {
const parsedValue = parseInt(value, 10);
return isNaN(parsedValue) ? 0 : Math.max(0, parsedValue);
}
function getTimerSections () {
const timerSections = document.getElementsByClassName("timer-section");
const timerSectionsLength = timerSections.length;
return {timerSections, timerSectionsLength};
}
一枚のファイルのコード量としては少し多くなったかなとは個人的に思いますが、流れ自体は追えると思うのでこのまま紹介します。
関数の名前だけを取り出してみると以下の通りです。
- startTimer
- stopTimer
- resetTimer
- moreTimer
- lessTimer
- ringBell
- validateInput
- getTimerSections
startTimer, stopTimer, resetTimerの三つがストップウォッチの操作に関係する関数で、moreTimer, lessTimerが指定できるタイミングの数を増やしたり減らしたりできる関数で、validateInput, getTimerSectionsは処理にかかるコードの共通部分を切り出したものです。more & less Timer関数はUIの動的な制御も行っています。
まず、moreTimerとlessTimerから紹介します。
function moreTimer() {
function createLabel(forAttr, text) {
const label = document.createElement('label');
label.setAttribute('for', forAttr);
label.textContent = text;
return label;
}
function createInput(id, type, min, placeholder) {
const input = document.createElement('input');
input.setAttribute('id', id);
input.setAttribute('type', type);
input.setAttribute('min', min);
if (placeholder) {
input.setAttribute('placeholder', placeholder);
}
return input;
}
const nextSectionIndex = getTimerSections().timerSectionsLength + 1;
const section = document.createElement('section');
section.id = `time-${nextSectionIndex}`;
section.className = "timer-section";
const div1 = document.createElement('div');
div1.appendChild(createLabel(`min-input-${nextSectionIndex}`, `時間${nextSectionIndex}`));
div1.appendChild(document.createElement('br'));
div1.appendChild(createLabel(`min-input-${nextSectionIndex}`, '分:'));
div1.appendChild(createInput(`min-input-${nextSectionIndex}`, 'number', '0', 'minutes'));
div1.appendChild(document.createElement('br'));
div1.appendChild(createLabel(`sec-input-${nextSectionIndex}`, '秒:'));
div1.appendChild(createInput(`sec-input-${nextSectionIndex}`, 'number', '0', 'seconds'));
const div2 = document.createElement('div');
div2.appendChild(createLabel(`count-input-${nextSectionIndex}`, 'ベルの回数:'));
div2.appendChild(createInput(`count-input-${nextSectionIndex}`, 'number', '0'));
div2.lastChild.setAttribute('max', '5');
section.appendChild(div1);
section.appendChild(div2);
timerContainer.appendChild(section);
}
function lessTimer(){
const {timerSections, timerSectionsLength }= getTimerSections();
if(timerSectionsLength <= 1) return;
const lastTimerSection = timerSections[timerSectionsLength - 1];
lastTimerSection.remove();
}
moreTimer関数の基本的な処理は、「HTMLのタイミングを指定するセクションを追加する」処理です。コードが長々と書かれていますが、これは基本的にbody内のtimer-sectionと同じHTMLコードを出力するための処理です。
ですので、moreTimer関数を実行すると例えば以下のようなHTML要素が出力されます。
<section id="time-5" class="timer-section">
<div>
<label for="min-input-5">時間5</label>
<br>
<label for="min-input-5">分:</label>
<input id="min-input-5" type="number" min="0" placeholder="minutes">
<br>
<label for="sec-input-5">秒:</label>
<input id="sec-input-5" type="number" min="0" placeholder="seconds">
</div>
<div>
<label for="count-input-5">ベルの回数:</label>
<input id="count-input-5" type="number" min="0" max="5">
</div>
</section>
lessTimer関数は内部的には、timer-sectionのHTMLCollection配列から一番最後のものを削除するようにしています。
要素の削除はremoveメソッドを実行するだけでよいのでとても楽ちんですね。
ついでにgetTimerSections関数も見ておきます。
function getTimerSections () {
const timerSections = document.getElementsByClassName("timer-section");
const timerSectionsLength = timerSections.length;
return {timerSections, timerSectionsLength};
}
classNameはidと違って幾つか重複して割り振ることができるので、getElementするとイテラブルなオブジェクト(HTMLCollection)が返ってきます。
ちなみに、HTMLCollectionは「生きた」オブジェクトですので、即ち新しい要素の追加や削除がオブジェクトにも反映されますので、取得時の状態を維持しておいてDOM操作の影響を受けないようにしたいのであればArray.from()メソッドで配列にして変数に代入しておくとよいです。
次に、ringBell関数を紹介します。
なにげに実装に苦労した部分です。
async function ringBell(count) {
let waitTime = count == 1 ? 2000 : count == 2 ? 900 : 550;
for(let i = 0; i < count; i ++){
if(i === count - 1) waitTime = 2000;
bellSound.play();
await new Promise(resolve => setTimeout(resolve,waitTime));
bellSound.pause();
bellSound.currentTime = 0;
}
}
bellSoundはAudioクラスのインスタンスであり、JavaScriptのファイル内で生成したものです。
一度再生するともう一度playメソッドを呼び出しても動きませんでした。
複数回再生するためには、loop値をtrueにする方法もありますが、今度はpauseメソッドがきかなかった覚えがあります。
何回か試したり調べたりする中で、playメソッドを実行した後にcurrentTimeを0にすることで、一度再生させた後でもふたたび再生させることができました。
waitTimeは事実上の再生時間を表していて、音が一回鳴らすよう指定されていたら2秒、2回なら0.9秒、それ以外(countは最小値を0にしてありますので3回以上)であれば0.5秒ほどの再生時間に絞っています。このうえで、連続再生の最後になったら余韻を残すためにまた2秒ほどの再生時間を確保するようにしました。
使ってもらったらすぐにわかるのですが、擬音で表現したらこんな感じになります。
一回:チーン
二回:チンチーン
三回:チチチーン
最後に、startTimer関数を紹介して終わります。
function startTimer() {
if (isRunning) return;
const timerSectionsLength = getTimerSections().timerSectionsLength;
const timeList = new Array(timerSectionsLength).fill(0).map((_,index) => {
const minutes = validateInput(document.getElementById(`min-input-${index+1}`).value);
const seconds = validateInput(document.getElementById(`sec-input-${index+1}`).value);
return minutes*60 + seconds;
});
const countList = new Array(timerSectionsLength).fill(0).map((_, index) => {
return validateInput(document.getElementById(`count-input-${index+1}`).value) || 1;
});
if(!timeList[0]) return;
isRunning = true;
currentTime = 0;
timer = setInterval(async() => {
currentTime++;
timerDisplay.textContent = currentTime <= 60 ? `${currentTime}秒` : `${Math.floor(currentTime / 60)}分${currentTime % 60}秒` ;
const index = timeList.indexOf(currentTime);
if(index !== -1){
await ringBell(countList[index]);
}
}, 1000);
}
動的にDOMが生成されるということは、スタートボタンが押された時点ではtimer-sectionの数が初期状態と違うかもしれないということです。
このため、他のDOM取得とは違って、タイミングの設定値やベルを鳴らす回数の値はスタートボタンが押されたときに取得する必要があります。
ここも地味に悩んだポイントで、結果だけ言えば現在のtimer-sectionのクラスの数を取得し、その長さ分だけ配列を生成してタグ内の値を入れるようにしました。
それが、
const countList = new Array(timerSectionsLength).fill(0).map((_, index) => {
return validateInput(document.getElementById(`count-input-${index+1}`).value) || 1;
});
の部分です。
配列の初期値としては0で埋めましたが、これ自体には興味がないのでmapの処理では使っていません。代わりにindexを使います。
indexには現在取得している値の添え字、配列における先頭からの順番の情報が入っているので、これを使って動的にDOM要素にアクセスすることができました。
indexの最初の値は0ですのでそこにだけ注意が必要です。
timeListとcountListのindexはセクションの順番と一致しているので、例えば二つ目のタイムセクションであればtimeList[1]で指定時間を、countList[1]でベルを鳴らす回数を取得できます。
const index = timeList.indexOf(currentTime);
if(index !== -1){
await ringBell(countList[index]);
}
現在の時間が指定の時間と一致したときのtimeListのindexでcountListにアクセスすることで、機能として欲しかった「任意のタイミングで任意の回数ベルを鳴らす」というコアの部分が実装できました。
ここまでで重要なところはだいたい説明し終えたので、これで解説は終わりにしようと思います。
3. おわりに
今回は三時間前後でさくっとシステムを作り終えて、一時間前後でさくっと記事も書き終えることができました。
モックレベルであれば時間をかけずに作れるくらいの実力はついたかなぁと思います。
今はGoの勉強もしているので、近いうちにGoの勉強で躓いたところをまとめておきたいなと思っています。
あまり丁寧な解説ではなかったと思いますが、どこか役に立つ部分があれば幸いです