複数の計測ユニットを表示してそれぞれ別々にタイム計測できます。
LAP機能は親ユニットのタイム/スプリットタイムを計測停止状態でコピーしたユニットを追加表示することでLAPとしています。
動作デモ
###HTML###
index.html
<!DOCTYPE html>
<html lang='ja'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='user-scalable=no,width=device-width,initial-scale=1'>
<title>stopwatch multiple</title>
<link rel='stylesheet' href='style.css'>
<script src='script.js'></script>
</head>
<body>
<div class='allControl'>
<button id='allReset'>ALL RESET</button>
<button id='export'>TEXT</button>
<button id='allStop'>ALL STOP</button>
<button id='allStart'>ALL START</button>
<button id='hold'>HOLD</button>
<select id='resolution'>
<option value='0'>1</option>
<option value='1'>1/10</option>
<option value='2' selected>1/100</option>
<option value='3'>1/1000</option>
</select>
<button id='numOfDay'>Day.Hour</button>
</div>
<div id='container'>
</div>
<div class='exportModal'>
<button class='close'>x</button>
<textarea class='text'></textarea>
</div>
</body>
</html>
###CSS###
style.css
body {
margin: 0;
min-width: 320px;
}
.unit {
position: relative;
width: fit-content;
border-radius: 3px;
margin: 12px;
padding: 8px;
display: inline-block;
background: #ddd;
}
.view {
font-size: 28px;
text-align: right;
padding-right: 4px;
line-height: 100%;
padding-top: 6px;
}
.lap {
font-size: 16px;
text-align: right;
padding-right: 4px;
}
.reset, .start {
height: 30px;
font-size: 16px;
cursor: pointer;
padding: 0;
}
.reset {
width: 75px;
}
.start {
width: 125px;
}
.unit button, #allReset, #export, #allStop, #allStart, #hold, #resolution, #numOfDay {
border-radius: 3px;
background-color: #fff;
-webkit-appearance: none;
-webkit-user-select: none;
border: 0;
cursor: pointer;
}
.close, .add {
width: 25px;
height: 25px;
padding: 0;
}
.unit .close {
background-color: #faa;
color: #fff;
}
.allControl {
position: sticky;
left: 0;
top: 0;
box-sizing: border-box;
width: 100%;
height: 50px;
background-color: #eeed;
padding: 8px;
z-index: 1;
}
#container {
padding-bottom: 40px;
}
#allReset {
border: 1px #f88 solid;
background-color: #fdd;
}
#export {
border: 1px #8ff solid;
}
#allStop, #allStart {
position: relative;
left: 20px;
}
#allStop {
border: 1px #c88 solid;
}
#allStart {
border: 1px #8c8 solid;
}
#hold {
position: relative;
left: 40px;
border: 1px #f88 solid;
width: 70px;
}
#resolution {
position: relative;
left: 40px;
border: 1px #88f solid;
width: 70px;
padding-left: 12px;
}
#allStop, #allStart, #hold, #resolution, #numOfDay {
height: 30px;
}
#numOfDay {
position: relative;
left: 40px;
border: 1px #88f solid;
width: 70px;
}
@media (max-width: 600px){
#allStop, #allStart, #hold, #resolution, #numOfDay {
left: auto;
height: 40px;
position: absolute;
}
#allStop, #allStart {
width: 120px;
right: 100px;
}
#hold, #resolution, #numOfDay {
right: 16px;
height: 25px;
}
#allStop, #hold {
top: 4px;
}
#allStart {
top: 50px;
}
#resolution {
top: 34px;
}
#numOfDay {
top: 64px;
}
.allControl {
height: 95px;
}
#export {
position: absolute;
left: 10px;
bottom: 10px;
}
}
.caption {
width: 138px;
border: 0;
}
.exportModal {
position: fixed;
background-color: #eeed;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 2;
display: none;
}
.exportModal .close {
border-radius: 3px;
background-color: #f88;
-webkit-appearance: none;
-webkit-user-select: none;
border: 1px solid #888;
color: #fff;
cursor: pointer;
position: absolute;
top: 10px;
right: 10px;
}
.exportModal .text {
position: relative;
width: 90%;
left: 5%;
top: 60px;
height: 200px;
}
###JavaScript###
script.js
'use strict';
const
storageKey = 'stopWatch_Multiple',
s = localStorage.getItem(storageKey),
storage = s !== null ? JSON.parse(s) : [],
obj = {},
cEvent = window.ontouchstart !== undefined ? 'touchstart' : 'mousedown',
resolutionArr = [19, 21, 22, 23];
let unitId = 0,
hold = 0,
resolution = 2,
numOfDay = 0;
window.addEventListener('DOMContentLoaded', function() {
// localStorageに状態保持データがあれば復元
if(storage.length) {
let tmp = 0;
for(let i in storage) {
addUnit(storage[i]);
if(storage[i].id > tmp) tmp = storage[i].id;
}
unitId = tmp + 1;
}
// 無ければ新規ユニット1個追加
else {
addUnit();
}
// 各モード状態復元
const
s = localStorage.getItem(storageKey + '_m'),
storageModeParameters = s !== null ? JSON.parse(s) : {};
if(storageModeParameters.hold !== undefined) {
hold = storageModeParameters.hold;
setHold();
}
if(storageModeParameters.resolution !== undefined) {
document.getElementById('resolution').value =
resolution = storageModeParameters.resolution;
setResolution();
}
if(storageModeParameters.numOfDay !== undefined) {
numOfDay = storageModeParameters.numOfDay;
setNumOfDay();
}
// 全スタートボタン
document.getElementById('allStart').addEventListener('click', function() {
const now = Date.now();
// 停止中の全ユニットスタート
for(let i in obj) {
const o = obj[i];
if(o.sTime <= 0) {
o.sTime += now;
o.lTime += now;
o.start.textContent = 'STOP';
o.reset.textContent = 'LAP';
o.start.style.backgroundColor = '#f88';
}
}
saveUnitParameters();
});
// 全ストップボタン
document.getElementById('allStop').addEventListener('click', function() {
const now = Date.now();
// 動作中の全ユニットストップ
for(let i in obj) {
const o = obj[i];
if(o.sTime > 0) {
o.sTime -= now;
o.lTime -= now;
o.view.textContent = timeString(-o.sTime);
o.lap.textContent = timeString(-o.lTime);
o.start.textContent = 'START';
o.reset.textContent = 'RESET';
o.start.style.backgroundColor = '#8f8';
}
}
saveUnitParameters();
});
// リセットボタン
document.getElementById('allReset').addEventListener('click', function() {
if(confirm('状態保持データを削除して計測ユニットをリセットします。\nReturns all operations to the initial state.')) {
allReset();
}
});
// 誤操作防止/解除ボタン
document.getElementById('hold').addEventListener('click', function(){
hold ^= 1;
setHold();
saveModeParameters();
});
// 1秒以下分解能セレクタ
document.getElementById('resolution').addEventListener('change', function() {
if(resolutionArr[this.value] === undefined) return;
resolution = this.value;
setResolution();
saveModeParameters();
});
// 日数表示ボタン
document.getElementById('numOfDay').addEventListener('click', function(){
numOfDay ^= 1;
setNumOfDay();
saveModeParameters();
});
// exportボタン
document.getElementById('export').addEventListener('click', function(){
const modal = document.querySelector('.exportModal');
exportText(modal.querySelector('.text'));
modal.style.display = 'block';
modal.querySelector('.close').addEventListener('click', function(){
modal.style.display = 'none';
});
});
count();
});
function setHold() {
for(let i in obj) {
const o = obj[i];
o.start.disabled =
o.reset.disabled =
o.add.disabled =
o.close.disabled =
o.caption.disabled = hold;
}
document.getElementById('allStop').disabled =
document.getElementById('allStart').disabled =
document.getElementById('allReset').disabled = hold;
const d = document.getElementById('hold');
if(hold % 2 === 1) {
d.style.backgroundColor = '#fcc';
}
else {
d.style.backgroundColor = '';
}
}
function setResolution() {
for(let i in obj) {
const o = obj[i];
if(o.sTime <= 0) {
o.view.textContent = timeString(-o.sTime);
o.lap.textContent = timeString(-o.lTime);
}
}
}
function setNumOfDay() {
for(let i in obj) {
const o = obj[i];
if(o.sTime <= 0) {
o.view.textContent = timeString(-o.sTime);
o.lap.textContent = timeString(-o.lTime);
}
}
const d = document.getElementById('numOfDay');
if(numOfDay % 2 === 1) {
d.style.backgroundColor = '#cff';
}
else {
d.style.backgroundColor = '';
}
}
function allReset() {
localStorage.removeItem(storageKey);
for(let i in obj) delete obj[i];
unitId = 0;
document.getElementById('container').innerHTML = '';
localStorage.removeItem(storageKey + '_m');
hold = 0;
document.getElementById('resolution').value = resolution = 2;
numOfDay = 0;
setHold();
setResolution();
setNumOfDay();
addUnit();
}
function count() {
const now = Date.now();
for(let i in obj) {
const o = obj[i];
if(o.sTime > 0) {
o.view.textContent = timeString(now - o.sTime);
o.lap.textContent = timeString(now - o.lTime);
}
}
requestAnimationFrame(count);
}
function timeString(time) {
const
s = numOfDay ? 11 : 14,
r = resolutionArr[resolution];
return (numOfDay ? Math.floor(time / 864e5) + '.' : Math.floor(time / 36e5) + ':') +
new Date(time).toISOString().slice(s, r);
}
// ユニット追加
function addUnit(storageObj) {
unitId++;
const id = storageObj !== undefined ? storageObj.id : unitId;
document.getElementById('container').appendChild(document.createElement('div'));
document.getElementById('container').lastChild.outerHTML =
"<div class='unit' id='u" + id + "'>" +
" <input type='text' class='caption' placeholder='-- No Caption --'>" +
" <button class='add'>+</button>" +
" <button class='close'>x</button>" +
" <div class='view'></div>" +
" <div class='lap'></div>" +
" <button class='reset'>RESET</button>" +
" <button class='start'>START</button>" +
"</div>";
const unit = document.getElementById('u' + id);
obj[id] = {
id : id,
close : unit.querySelector('.close'),
add : unit.querySelector('.add'),
reset : unit.querySelector('.reset'),
start : unit.querySelector('.start'),
view : unit.querySelector('.view'),
lap : unit.querySelector('.lap'),
caption: unit.querySelector('.caption'),
sTime : storageObj !== undefined ? storageObj.sTime : 0,
lTime : storageObj !== undefined ? storageObj.lTime : 0,
};
if(Object.keys(obj).length === 1 && !(storageObj !== undefined && storage.length > 1)) {
closeButtonDisplay(false);
}
const o = obj[id];
o.view.textContent = timeString(-o.sTime);
o.lap.textContent = timeString(-o.lTime);
if(o.sTime > 0) {
o.start.textContent = 'STOP';
o.reset.textContent = 'LAP';
o.start.style.backgroundColor = '#f88';
}
else {
o.start.style.backgroundColor = '#8f8';
}
if(storageObj !== undefined) {
o.caption.value = storageObj.caption;
}
// クローズボタン
o.close.addEventListener('click', function() {
document.getElementById('container').removeChild(
document.getElementById('u' + o.id)
);
delete obj[o.id];
if(Object.keys(obj).length === 1) {
closeButtonDisplay(false);
}
saveUnitParameters();
});
// 追加ボタン
o.add.addEventListener('click', function() {
addUnit();
if(Object.keys(obj).length === 2) {
closeButtonDisplay(true);
}
saveUnitParameters();
});
// リセットボタン
o.reset.addEventListener(cEvent, function() {
if(this.disabled) return;
const now = Date.now();
// 停止中
if(o.sTime <= 0) {
// リセット
o.sTime = o.lTime = 0;
o.view.textContent = timeString(o.sTime);
o.lap.textContent = timeString(o.lTime);
}
// 計測中
else {
// LAP(メイン及びスプリットタイムを新規ユニットに計測停止状態で複製)
const newCaption = o.caption.value.replace(/(:Lap)?$/, ':Lap');
addUnit({
id : unitId + 1,
caption: newCaption,
sTime : o.sTime - now,
lTime : o.lTime - now,
});
// 親ユニットのスプリットタイムをリセット
o.lTime = now;
if(Object.keys(obj).length === 2) {
closeButtonDisplay(true);
}
}
saveUnitParameters();
});
// スタートボタン
o.start.addEventListener(cEvent, function() {
if(this.disabled) return;
const now = Date.now();
// 計測中
if(o.sTime > 0) {
// 計測停止
o.sTime -= now;
o.lTime -= now;
o.view.textContent = timeString(-o.sTime);
o.lap.textContent = timeString(-o.lTime);
o.start.textContent = 'START';
o.reset.textContent = 'RESET';
o.start.style.backgroundColor = '#8f8';
}
// 停止中
else {
// 計測開始・再開
o.sTime += now;
o.lTime += now;
o.start.textContent = 'STOP';
o.reset.textContent = 'LAP';
o.start.style.backgroundColor = '#f88';
}
saveUnitParameters();
});
// キャプション
o.caption.addEventListener('input', function() {
saveUnitParameters();
});
}
function closeButtonDisplay(f) {
document.querySelector('#container .unit .close').style.display = f ? '' : 'none';
}
function saveUnitParameters() {
const data = [];
for(let i in obj) {
const o = obj[i];
data.push({
id : o.id,
sTime : o.sTime,
lTime : o.lTime,
caption: o.caption.value
});
}
// ユニット1個、時間0で停止、キャプションなしの場合は削除
if(data.length === 1 && data[0].sTime === 0 && data[0].caption === '') {
localStorage.removeItem(storageKey);
}
else {
localStorage.setItem(storageKey, JSON.stringify(data));
}
}
function saveModeParameters() {
const
key = storageKey + '_m',
data = {
hold : +hold,
resolution: +resolution,
numOfDay : +numOfDay,
};
localStorage.setItem(key, JSON.stringify(data));
}
function exportText(e) {
let str = '';
const now = Date.now();
for(let i in obj) {
const o = obj[i];
const l = [];
l.push('"' + o.caption.value.replace(/"/g, '""') + '"');
l.push(timeString(o.sTime > 0 ? now - o.sTime : -o.sTime));
l.push(timeString(o.lTime > 0 ? now - o.lTime : -o.lTime));
str += l.join(', ') + "\n";
}
e.value = '"' + new Date().toLocaleString() + "\"\n" + str;
}