複数計測対応ストップウォッチ

複数の計測ユニットを表示してそれぞれ別々にタイム計測できます。
LAP機能は親ユニットのタイム/スプリットタイムを計測停止状態でコピーしたユニットを追加表示することでLAPとしています。
![image.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618708/f8cfd0a5-34ed-40ab-2f79-0fb2c86331bc.jpeg)
[**動作デモ**](https://akebi.jp/temp/stopwatch_multiple/)

###HTML###

```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###

```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###

```js: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;
}
```