【JavaScript】数値のカウントアップ・ダウン

採用サイトでよく見かけるようになった、スクロールで数値がカウントアップ表示されるアレ。

* プラグインを利用したくない
* HTMLのdata属性でオプションを設定したい

というわけで、自作してみました！

## 仕様

### 全般

* 文書構造を考慮し、HTMLにはカウント後の数値を設定する
* カウントしたい要素にCSSで `opacity: 0;` を設定する
  * JavaScript でカウント後からカウント開始数へと変更しているため読み込み時のチラツキ対策
* カウントしたい要素にclass（`js-counter`）を設定する
* 画面と数値が交差した時にclass（`is-inview`）が付与されたらカウント開始

### オプション

カウントのオプションはHTMLのdata属性に設定します。

| data属性             | デフォルト | 設定内容 |
| --------------------|----------|-------------- |
| data-count-start    | 0        | カウント開始数 |
| data-count-duration | 2000     | アニメーション秒数（ミリ秒） |
| data-count-easing   | なし      | イージング名 |

イージングについては止まる時に適用したいので、以下の4つが設定可能です。

* easeOutQuad
* easeOutCubic
* easeOutQuart
* easeOutQuint

## JavaScript

### Counter

```javascript:JS
class Counter {
  #defaults
  #options
  #easings
  #counterList
  constructor(config) {
    this.#defaults = {
      selector: '.js-counter',
      duration: 2000,
      easing: null,
      inviewClass: 'is-inview'
    }
    this.#options = { ...this.#defaults, ...config }
    this.#easings = {
      easeOutQuad: (t) => t * (2 - t),
      easeOutCubic: (t) => --t * t * t + 1,
      easeOutQuart: (t) => 1 - --t * t * t * t,
      easeOutQuint: (t) => 1 + --t * t * t * t * t
    }
    this.#counterList = document.querySelectorAll(this.#options.selector)
    this.#init()
  }

  #init() {
    for (const el of this.#counterList) {
      this.#setStartNumber(el)
      this.#observeNumber(el)
    }
  }

  // カウント開始数を設定
  #setStartNumber(el) {
    const start = Number(el.dataset.countStart) || 0
    el.dataset.countEnd = el.textContent
    el.textContent = start
    el.style.opacity = 1
  }

  // 数値のclass属性を監視
  #observeNumber(el) {
    // オブザーバーの作成
    const observer = new MutationObserver(() => {
      // 画面と数値が交差した時のclassが付与されたら
      if (el.classList.contains(this.#options.inviewClass)) {
        this.countNumber(el)
      }
    })
    // 監視の開始
    observer.observe(el, {
      attributes: true,
      attributeFilter: ['class']
    })
  }

  // 数値のカウント
  countNumber(el) {
    const start = Number(el.dataset.countStart) || 0
    const endStr = el.dataset.countEnd
    const end = parseFloat(endStr)
    const duration = Number(el.dataset.countDuration) || this.#options.duration
    const decimal = endStr.split('.')[1]
    const digits = typeof decimal === 'undefined' ? 0 : decimal.length
    const easing = el.dataset.countEasing
      ? this.#easings[el.dataset.countEasing]
      : this.#easings[this.#options.easing]
    let startTime = null
    // ブラウザのフレームを更新するタイミング（16ms）毎に実行する関数
    const countAnimation = (currentTime) => {
      if (!startTime) startTime = currentTime
      let progress = Math.min((currentTime - startTime) / duration, 1)

      // イージングの設定
      if (easing) {
        progress = easing(progress)
      }

      // htmlに数値を設定
      el.textContent = digits
        ? (progress * (end - start) + start).toFixed(digits)
        : Math.floor(progress * (end - start) + start)

      // カウントアニメーションを終了
      if (progress < 1) {
        requestAnimationFrame(countAnimation)
      }
    }
    requestAnimationFrame(countAnimation)
  }
}

```

### Inview

```javascript:JS
class Inview {
  #defaults
  #options
  #inviewList
  constructor(config) {
    this.#defaults = {
      selector: '.js-inview',
      inviewClass: 'is-inview'
    }
    this.#options = { ...this.#defaults, ...config }
    this.#inviewList = document.querySelectorAll(this.#options.selector)
    this.#init()
  }

  #init() {
    for (const el of this.#inviewList) {
      const options = {
        rootMargin: el.dataset.inviewRootMargin || '0px'
      }
      const callback = (entries) => {
        for (const entry of entries) {
          const isInviewToggle = el.dataset.inviewToggle === ''
          // 画面と要素が交差
          if (entry.isIntersecting) {
            el.classList.add(this.#options.inviewClass)
            // target.dataset.inviewToggle属性がない場合
            // 表示後は監視を解除
            if (!isInviewToggle) {
              observer.unobserve(el)
            }
          }
          // data-inview-toggle属性がある場合
          // 画面内から消えたら要素のclassを削除
          if (!entry.isIntersecting && isInviewToggle) {
            el.classList.remove(this.#options.inviewClass)
          }
        }
      }
      const observer = new IntersectionObserver(callback, options)
      observer.observe(el)
    }
  }
}
```

## DEMO

<p class="codepen" data-height="500" data-default-tab="result" data-slug-hash="Bamjypa" data-user="taqumo" style="height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/taqumo/pen/Bamjypa">
  Number Count Up Down</a> by Takuya Mori (<a href="https://codepen.io/taqumo">@taqumo</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>