スピードメータのようなゲージを作る

![ezgif-5b77e6c6547533.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117164/6352cb28-eb49-4c58-8aef-521474c63d7b.gif)

```html
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Radial Gauge Example</title>
    <style>
      .radial-gauge-svg {
        width: 300;
        height: 300;
      }

      .radial-gauge-svg > g > text,
      g > text {
        text-anchor: middle;
        dominant-baseline: alphabetic;
        font-weight: normal;
        stroke: none;
      }

      .radial-gauge-path {
        fill: none;
        stroke: gainsboro;
        stroke-width: 8;
      }

      .radial-gauge-path-filled {
        fill: none;
        stroke: lightskyblue;
        stroke-width: 8;
      }

      .radial-gauge-title-text {
        font-size: 0.28rem;
        text-anchor: middle;
        alignment-baseline: middle;
        dominant-baseline: central;
        fill: gray;
      }

      .radial-gauge-units-text {
        font-size: 0.22rem;
        text-anchor: middle;
        alignment-baseline: middle;
        dominant-baseline: central;
        fill: gray;
      }

      .radial-gauge-value-text {
        font-size: 0.55rem;
        font-family: sans-serif;
        font-weight: normal;
        text-anchor: middle;
        alignment-baseline: middle;
        dominant-baseline: central;
        fill: dimgray;
      }

      .radial-gauge-circle {
        fill: orangered;
      }

      .radial-gauge-needle {
        fill: unset;
        stroke-width: 0.4;
        stroke: orangered;
      }

      .radial-gauge-scale-text {
        font-size: 0.22rem;
        fill: dimgray;
      }

      .radial-gauge-tick {
        stroke: darkgray;
        stroke-width: 0.4;
      }

      .gauge210 {
        display: inline-block;
        border: 0.5px solid darkgray;
        box-sizing: border-box;
      }
      .gauge270 {
        display: inline-block;
        border: 0.5px solid darkgray;
        box-sizing: border-box;
      }
    </style>
    <script>
      ;(function (global, factory) {
        const Gauge = factory(global)

        if (typeof define === 'function' && define.amd) {
          // AMD support
          define(() => Gauge)
        } else if (typeof module === 'object' && module.exports) {
          // CommonJS support
          module.exports = Gauge
        } else {
          // Browser global
          global.Gauge = Gauge
        }
      })(typeof window === 'undefined' ? this : window, (global) => {
        const document = global.document
        const requestAnimationFrame = global.requestAnimationFrame || global.mozRequestAnimationFrame || global.webkitRequestAnimationFrame || global.msRequestAnimationFrame || ((cb) => setTimeout(cb, 1000 / 60))

        const SVG_NS = 'http://www.w3.org/2000/svg'

        /**
         * Animation utility for smooth transitions.
         */
        class Animation {
          constructor(options) {
            this.duration = options.duration
            this.start = options.start || 0
            this.end = options.end
            this.change = this.end - this.start
            this.step = options.step
            this.easing =
              options.easing ||
              ((pos) => {
                if ((pos /= 0.5) < 1) return 0.5 * Math.pow(pos, 3)
                return 0.5 * (Math.pow(pos - 2, 3) + 2)
              })
            this.currentIteration = 1
            this.iterations = 60 * this.duration
          }

          animate() {
            const progress = this.currentIteration / this.iterations
            const value = this.change * this.easing(progress) + this.start
            this.step(value, this.currentIteration)
            this.currentIteration += 1

            if (progress < 1) {
              requestAnimationFrame(() => this.animate())
            }
          }

          startAnimation() {
            requestAnimationFrame(() => this.animate())
          }
        }

        /**
         * Utility functions for SVG and math operations.
         */
        const Utils = {
          createSVGElement(label, attrs, children) {
            const elem = document.createElementNS(SVG_NS, label)
            for (const attrName in attrs) {
              elem.setAttribute(attrName, attrs[attrName])
            }
            if (children) {
              children.forEach((child) => elem.appendChild(child))
            }
            return elem
          },

          getAngle(percentage, gaugeSpanAngle) {
            return (percentage * gaugeSpanAngle) / 100
          },

          normalize(value, min, max) {
            let val = Number(value)
            if (val < 0) val += min
            return Math.min(val, max)
          },

          getValueInPercentage(value, min, max) {
            const newMax = max - min
            const newVal = value - min
            return (100 * newVal) / newMax
          },

          getCartesian(cx, cy, radius, angle) {
            const rad = (angle * Math.PI) / 180
            return {
              x: Math.round((cx + radius * Math.cos(rad)) * 1000) / 1000,
              y: Math.round((cy + radius * Math.sin(rad)) * 1000) / 1000,
            }
          },

          getDialCoords(radius, startAngle, endAngle) {
            const cx = 50
            const cy = 50
            return {
              start: this.getCartesian(cx, cy, radius, startAngle),
              end: this.getCartesian(cx, cy, radius, endAngle),
            }
          },

          pathString(radius, startAngle, endAngle, largeArc = 1) {
            const { start, end } = this.getDialCoords(radius, startAngle, endAngle)
            return ['M', start.x, start.y, 'A', radius, radius, 0, largeArc, 1, end.x, end.y].join(' ')
          },
        }

        /**
         * Gauge class for creating and managing the gauge.
         */
        class Gauge {
          constructor(container, options = {}) {
            this.container = container
            this.options = { ...Gauge.defaultOptions, ...options }
            this.initialize()
          }

          static defaultOptions = {
            gaugeSize: 45,
            offset: 10,

            displayNeedle: true,
            displayScale: true,
            displaySmallScale: true,
            displayValue: true,
            precision: 2,
            title: 'Speed',
            units: 'Km/h',
            gaugeColor: null,
          }

          initialize() {
            const { gaugeSize, offset, value, displayNeedle } = this.options
            this.radius = gaugeSize - offset
            this.createSVGElements()
            this.updateGauge(value)
            if (displayNeedle) this.drawNeedle()
          }

          createSVGElements() {
            const { startAngle, endAngle, title, units, displayNeedle, displayScale, needleY, titleY, unitsY, valueY } = this.options

            // Create SVG elements
            this.titleText = Utils.createSVGElement('text', { x: 50, y: titleY, class: 'radial-gauge-title-text' }, [document.createTextNode(title)])
            this.unitsText = Utils.createSVGElement('text', { x: 50, y: unitsY, class: 'radial-gauge-units-text' }, [document.createTextNode(units)])
            this.valueText = Utils.createSVGElement('text', { x: 50, y: valueY, class: 'radial-gauge-value-text' })

            this.barPath = Utils.createSVGElement('path', { class: 'radial-gauge-path', d: Utils.pathString(this.radius, startAngle, endAngle) })
            this.barFilledPath = Utils.createSVGElement('path', { class: 'radial-gauge-path-filled', d: Utils.pathString(this.radius, startAngle, startAngle) })

            this.rootSvg = Utils.createSVGElement('svg', { viewBox: '0 0 100 100', class: 'radial-gauge-svg' }, [this.barPath, this.barFilledPath, this.valueText, this.titleText, this.unitsText])

            if (displayNeedle) {
              this.rootSvg.appendChild(Utils.createSVGElement('circle', { class: 'radial-gauge-circle', cx: 50, cy: needleY, r: 2 }))
            }

            if (displayScale) {
              this.createScale()
            }

            this.container.appendChild(this.rootSvg)
          }

          createScale() {
            const { gaugeSize, startAngle, endAngle, min, max, ticks, displaySmallScale } = this.options
            const scaleGroup = Utils.createSVGElement('g', { class: 'radial-gauge-scale' })

            const startTick = startAngle + 90
            const factor = (360 - (startAngle - endAngle)) / (ticks * 10)

            for (let n = 0; n <= ticks * 10; n++) {
              let yT = 50 - gaugeSize + gaugeSize / 10 - (gaugeSize > 40 ? 2 : 0)
              let dT = n % 10 === 0 ? 5 : 2
              if (n % 10 === 0) {
                yT -= 3
              }
              if (n % 10 === 0 || displaySmallScale) {
                const tickLine = Utils.createSVGElement('line', {
                  x1: 50,
                  y1: yT,
                  x2: 50,
                  y2: yT + dT,
                  class: 'radial-gauge-tick',
                  transform: `rotate(${n * factor + startTick} 50 50)`,
                })

                scaleGroup.appendChild(tickLine)

                if (n % 10 === 0) {
                  const scaleText = Utils.createSVGElement(
                    'text',
                    {
                      x: 50,
                      y: yT - 1,
                      class: 'radial-gauge-scale-text',
                      transform: `rotate(${n * factor + startTick} 50 50)`,
                    },
                    [document.createTextNode((n * (max / ticks / 10) + min).toFixed(0))],
                  )
                  scaleGroup.appendChild(scaleText)
                }
              }
            }

            this.rootSvg.appendChild(scaleGroup)
          }

          drawNeedle() {
            const { id, needleY } = this.options
            const needleCoord = this.barFilledPath.getAttribute('d').split(' ')
            let prev = document.querySelector(`.id-${id}`)
            if (prev) {
              prev.remove()
            }
            const needle = Utils.createSVGElement('line', {
              class: `id-${id} radial-gauge-needle`,
              x1: 50,
              y1: needleY,
              x2: needleCoord[needleCoord.length - 2],
              y2: needleCoord[needleCoord.length - 1],
            })

            this.rootSvg.appendChild(needle)
          }

          updateGauge(value) {
            const { min, max, startAngle, endAngle, precision, displayValue, displayNeedle } = this.options
            const percentage = Utils.getValueInPercentage(value, min, max)
            const angle = Utils.getAngle(percentage, 360 - Math.abs(startAngle - endAngle))
            const flag = angle <= 180 ? 0 : 1

            this.barFilledPath.setAttribute('d', Utils.pathString(this.radius, startAngle, angle + startAngle, flag))
            if (displayValue) {
              this.valueText.textContent = value.toFixed(precision)
            }
            if (displayNeedle) {
              this.drawNeedle()
            }
          }

          setValue(value) {
            const { min, max } = this.options
            this.options.value = Utils.normalize(value, min, max)
            this.updateGauge(this.options.value)
          }

          setValueAnimated(value, duration = 1) {
            const { value: oldValue, min, max } = this.options
            this.options.value = Utils.normalize(value, min, max)

            new Animation({
              start: oldValue,
              end: this.options.value,
              duration,
              step: (val) => this.updateGauge(val),
            }).startAnimation()
          }

          getValue() {
            return this.options.value
          }

          getRange() {
            const { min, max } = this.options
            return { min, max }
          }
        }

        return Gauge
      })
    </script>
  </head>

  <body>
    <div class="gauge210"></div>
    <div class="gauge270"></div>

    <script>
      const gauge210 = new Gauge(document.querySelector('.gauge210'), {
        id: '210',
        min: 0,
        max: 210,
        value: 100,
        ticks: 7,
        startAngle: 165,
        endAngle: 15,
        titleY: 35,
        unitsY: 41,
        needleY: 50,
        valueY: 62,
      })

      const gauge270 = new Gauge(document.querySelector('.gauge270'), {
        id: '270',
        min: 0,
        max: 270,
        value: 100,
        ticks: 9,
        startAngle: 135,
        endAngle: 45,
        titleY: 40,
        unitsY: 46,
        needleY: 60,
        valueY: 78,
      })

      setInterval(function () {
        const randomValue210 = (Math.random() * gauge210.getRange().max).toFixed(2)
        gauge210.setValueAnimated(parseFloat(randomValue210), 0.5)

        const randomValue270 = (Math.random() * gauge270.getRange().max).toFixed(2)
        gauge270.setValueAnimated(parseFloat(randomValue270), 0.5)
      }, 1000)
    </script>
  </body>
</html>