複数の計測に対応したカウントダウンタイマーです。
タイムアップ時、AudioContextのOscillatorNodeを使用した簡単なアラームも鳴ります。
鳴っているアラームは画面上の任意の場所をクリックするか、30秒経過で止まります。
計測時間設定に使用しているtype=timeのinput要素を1stepにして秒単位までの設定を行なえるようにしていますが、iPhoneやiPadでは秒の入力はサポートされていないようなので分単位までの設定となります。
countdown_timer.html
<!DOCTYPE html>
<html lang='ja'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>countdown timer</title>
<style>
.unit {
position: relative;
overflow: hidden;
background-color: #ccd;
border-radius: 10px;
padding: 8px;
margin: 12px;
display: inline-block;
}
input, button {
color: #333;
border: 1px #777 solid;
border-radius: 3px;
}
button {
background-color: #eee;
}
button:disabled, input[type=time]:disabled {
opacity: 0.5;
}
.i, .v {
display: none;
}
.p {
text-align: center;
width: 220px;
font-size: 40px;
font-family: monospace;
}
.progress_frame {
margin-top: 2px;
background-color: #0003;
}
.progress {
border-top: 3px #45d solid;
width: 100%;
transition: all 1s linear;
}
.row {
display: flex;
width: 220px;
margin-top: 5px;
}
.time {
min-width: 95px;
margin-right: 2px;
}
.caption {
width: 100%;
}
.reset, .start {
min-width: 100px;
margin: 0;
margin-right: 2px;
font-size: 18px;
}
.header {
background-color: #fff;
position: sticky;
top: 0;
padding: 4px;
border-bottom: 1px solid gray;
z-index: 1;
}
.info {
font-weight: bold;
font-size: 12px;
color: #c44;
padding-top: 4px;
display: none;
z-index: 1;
}
.info_active {
display: block;
}
body {
margin: 0;
}
</style>
</head>
<body>
<div class='header'>
<button class='allreset'>ALL RESET</button>
<button class='add'>ADD UNIT</button>
<div class='info'>
If this message is displayed, the alarm will not sound.
To sound the alarm, click or tap anywhere on the screen.
</div>
</div>
<div id='container'>
</div>
<script>
'use strict';
// 設定時間デフォルト
const defaultTime = '00:10:00';
// 動作状態ごとの背景色
const bgcolor = {
run: 'lightgreen',
end: 'lightcoral',
};
// localStrage用オブジェクト
const storage = {
name: 'countdownTimer_Multiple',
};
storage.data = JSON.parse(localStorage.getItem(storage.name)) ?? [];
window.addEventListener('DOMContentLoaded', () => {
// storageデータなし
if(storage.data.length === 0) {
addUnit(); // 初期unit表示
}
// storageデータあり
else {
for(const r of storage.data) {
addUnit(); // unit追加
const
unit = document.getElementById('container').querySelector('.unit:nth-last-child(1)'),
v = unit.querySelector('.v'),
p = unit.querySelector('.p'),
t = unit.querySelector('.time');
// プロパティ復帰
v.value = r.v;
p.value = r.p;
t.value = r.time === '' ? defaultTime : r.time;
if(r.c) unit.querySelector('.caption').value = r.c;
// 計測中であれば計測状態も復帰
if(r.v > 0) {
countdown(unit.querySelector('.i'), v, t);
unit.querySelector('.start').textContent = 'STOP';
unit.querySelector('.reset').disabled = true;
t.disabled = true;
const f = t.valueAsNumber - Math.floor((Date.now() - r.v) / 1000) * 1000 - 1000 >= 0;
p.style.backgroundColor = f ? bgcolor.run : bgcolor.end;
if(!f) unit.querySelector('.progress').style.width = 0;
else document.querySelector('.info').classList.add('info_active');
}
else {
updateProgress(t, ((t.valueAsNumber - -r.v) / 1000) * 1000);
}
}
}
// containerへのinputイベント発生時
document.getElementById('container').addEventListener('input', e => {
const
target = e.target, // container内で実際にイベント発生した要素
unit = target.closest('.unit'); // 対象要素の親unit
// time入力操作時の計測時間設定
if(target.classList.contains('time')) {
unit.querySelector('.v').value = 0;
const t = unit.querySelector('input[type=time]');
t.style.backgroundColor = '';
const p = unit.querySelector('.p');
p.value = getFormat(isNaN(t.valueAsNumber) ? 0 : t.valueAsNumber);
p.style.backgroundColor = '';
updateProgress(t, t.valueAsNumber);
}
});
// containerへのclickイベント発生時
document.getElementById('container').addEventListener('click', e => {
const
target = e.target, // container内で実際にイベント発生した要素
unit = target.closest('.unit'); // 対象要素の親unit
if(!unit) return;
const
cl = target.classList, // 対象要素のclassList
// unit内の各要素
t = unit.querySelector('.time'), // 計測時間入力
p = unit.querySelector('.p'), // 残時間表示
id = unit.querySelector('.i'), // setTimeoutの戻り値保持
n = unit.querySelector('.v'), // start/stopボタン押下時の時間保持
resetBtn = unit.querySelector('.reset'); // resetボタン
const reset = () => {
p.value = getFormat(t.valueAsNumber);
n.value = 0;
p.style.backgroundColor = '';
updateProgress(t, t.valueAsNumber);
};
// resetボタン押下時
if(cl.contains('reset') && n.value <= 0) {
reset();
}
// startボタン押下時
else if(cl.contains('start')) {
// 停止中なら計測開始
if(n.value <= 0) {
if(isNaN(t.valueAsNumber) || t.valueAsNumber === 0) return;
n.value -= -Date.now();
countdown(id, n, t);
target.textContent = 'STOP';
resetBtn.disabled = true;
t.disabled = true;
p.style.backgroundColor = bgcolor.run;
}
// 計測中なら停止
else {
n.value -= Date.now();
clearTimeout(id.value);
target.textContent = 'START';
resetBtn.disabled = false;
t.disabled = false;
p.style.backgroundColor = '';
updateProgress(t, t.valueAsNumber - -n.value);
// 0に到達済みならリセット
if(t.valueAsNumber - -n.value < 0) {
reset();
}
}
}
// 削除ボタン押下時
else if(cl.contains('del')) {
// 計測中であればTimeout解除
if(!isNaN(parseInt(id.value))) {
clearTimeout(id.value);
}
// unit削除
removeUnit(unit);
}
});
// unit追加ボタン押下時
document.querySelector('.add').addEventListener('click', () => {
addUnit();
});
// all resetボタン押下時
document.querySelector('.allreset').addEventListener('click', () => {
if(confirm('状態保持データを削除して計測ユニットをリセットします。\nReturns all operations to the initial state.')) {
localStorage.removeItem(storage.name);
for(const e of document.querySelectorAll('#container .i')) {
if(e.value !== '') clearTimeout(e.value);
}
document.getElementById('container').textContent = '';
addUnit();
}
});
});
// ページ非表示時
window.addEventListener('pagehide', () => updateStorage());
window.addEventListener('blur', () => updateStorage());
// unit削除
const removeUnit = unit => {
const s = getComputedStyle(unit);
unit.style.width = s.width;
unit.style.height = s.height;
unit.style.transition = 'all .3s';
setTimeout(() => {
unit.style.height = 0;
unit.style.opacity = 0;
});
setTimeout(() => {
unit.style.width = 0;
}, 160);
setTimeout(() => {
unit.remove();
adjustUnitStyle();
}, 300);
};
// localStorage更新
const updateStorage = () => {
const units = document.getElementById('container').querySelectorAll('.unit');
// 状態復帰に必要な各unitの要素保持
const params = [];
for(const unit of units) {
params.push({
v: unit.querySelector('.v').value,
p: unit.querySelector('.p').value,
time: unit.querySelector('.time').value,
c: unit.querySelector('.caption').value,
});
}
// unitが1個で各プロパティが初期値の場合はstorageデータ削除
if(params.length === 1) {
const p = params[0];
if(p.v === '0' && p.c === '' && p.p === defaultTime && p.time === defaultTime) {
localStorage.removeItem(storage.name);
return;
}
}
localStorage.setItem(storage.name, JSON.stringify(params));
};
// containerにunit追加
const addUnit = () => {
document.getElementById('container').insertAdjacentHTML('beforeend',`
<div class='unit'>
<input type='text' class='i'>
<input type='number' class='v' value='0'>
<input type='text' class='p' value='${defaultTime}' readonly>
<div class='progress_frame'>
<div class='progress'></div>
</div>
<div class='row'>
<input type='time' class='time' value='${defaultTime}' step='1'>
<input type='text' class='caption' placeholder='-- No Caption --'>
</div>
<div class='row'>
<button class='reset'>RESET</button>
<button class='start'>START</button>
<button class='del'>×</button>
</div>
</div>
`);
adjustUnitStyle();
};
// unit表示調整
const adjustUnitStyle = () => {
const units = document.querySelectorAll('#container .unit');
// 全unit数が1個の場合は削除ボタン非表示
units[0].querySelector('.del').style.display =
units.length === 1 ? 'none' : 'inline';
// 表示部のフォントサイズ調整
setTimeout(() => {
for(const unit of units) {
fontSizeAdjust(unit.querySelector('.p'));
}
});
};
// カウントダウン
const countdown = (id, n, t) => {
const
d = Date.now() - n.value,
num = t.valueAsNumber - Math.floor(d / 1000) * 1000 - 1000,
p = t.closest('.unit').querySelector('.p');
// 表示更新
p.value = getFormat(num);
updateProgress(t, num);
// 残時間あり
if(num >= 0) {
// 残時間の秒の切り替わりタイミングを目標としてtimeout設定
id.value = setTimeout(countdown, 1000 - d % 1000, id, n, t);
}
// 0に到達
else {
p.value = '00:00:00';
p.style.backgroundColor = bgcolor.end;
alarmStart(p);
}
};
// 時間表示フォーマット
const getFormat = d => new Date(+d).toISOString().slice(11, 19);
// 進捗バー更新
const updateProgress = (e, num) => {
const
unit = e.closest('.unit'),
t = unit.querySelector('.time'),
progress = unit.querySelector('.progress'),
per = num / t.valueAsNumber * 100;
progress.style.transition = (per === 100) ? 'all 0.3s' : 'all 1s linear';
progress.style.width = `${per}%`;
};
// 表示部からはみ出るフォントサイズの場合に収まるサイズまで下げる
const fontSizeAdjust = e => {
const t = e.value;
e.value = '';
const
cs = window.getComputedStyle(e),
defaultFontSize = parseFloat(cs.fontSize),
defaultScrollWidth = e.scrollWidth;
e.value = '00:00:00';
e.style.fontSize = defaultFontSize + 'px';
for(let i = defaultFontSize; i >= 1; i /= 1.02) {
if(e.scrollWidth <= defaultScrollWidth) {
break;
}
e.style.fontSize = i + 'px';
}
e.value = t;
};
// アラーム関連
const AudioContext = window.AudioContext || window.webkitAudioContext;
const al = {};
document.addEventListener('click', () => {
if(AudioContext === undefined) return;
setAudioContext();
alarmStop();
document.querySelector('.info').classList.remove('info_active');
});
const setAudioContext = () => {
if(al.context) return;
al.context = new AudioContext();
setOscillator();
al.osc.start(0);
al.osc.stop(0);
};
const setOscillator = () => {
al.osc = al.context.createOscillator();
al.osc.type = 'sawtooth';
al.gain = al.context.createGain();
al.osc.connect(al.gain);
al.gain.connect(al.context.destination);
al.osc.frequency.value = 3520;
al.gain.gain.value = 0.4;
};
const alarmStart = e => {
if(al.context === undefined) return;
alarmStop();
const t = 1000 / 8;
let count = 30;
const alarm = () => {
setOscillator();
al.gain.gain.value = 0;
al.osc.start(0);
for(let i = 0; i < 4; i++) {
setTimeout(() => {
e.style.backgroundColor = '';
al.gain.gain.value = 0.4;
}, t * i);
setTimeout(() => {
e.style.backgroundColor = bgcolor.end;
al.gain.gain.value = 0;
}, t / 2 + t * i);
}
setTimeout('al.osc.stop(0);', 500);
if(--count) al.id = setTimeout(alarm, 1000);
else setTimeout(alarmStop, 500);
};
if(al.id === undefined) {
alarm();
e.focus();
}
};
const alarmStop = () => {
if(al.id !== undefined) {
al.osc.stop(0);
clearTimeout(al.id);
delete al.id;
}
};
</script>
</body>
</html>