複数計測対応カウントダウンタイマー

複数の計測に対応したカウントダウンタイマーです。

![ss.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618708/9a03bd06-be8d-ecac-f50b-21b0e6f625d0.jpeg)

**[動作デモ](https://akebi.jp/temp/countdown_timer.html)**

タイムアップ時、AudioContextのOscillatorNodeを使用した簡単なアラームも鳴ります。
鳴っているアラームは画面上の任意の場所をクリックするか、30秒経過で止まります。

計測時間設定に使用しているtype=timeのinput要素を1stepにして秒単位までの設定を行なえるようにしていますが、iPhoneやiPadでは秒の入力はサポートされていないようなので分単位までの設定となります。

----

```html: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>
```

#### 関連記事 ####
[複数計測対応ストップウォッチ](https://qiita.com/akebi_mh/items/4279b038fe3436136aea)
[カウントダウンタイマー](https://qiita.com/akebi_mh/items/303383e76267d41347d8)

----