<div id="app">
  <div id="canvasWrapper" :style="{ width: props.width + 'px', height: props.height + 'px' }">
    <canvas ref="canvasRef" @click="props.onClick"></canvas>
  </div>
</div>
html, body {
  font-size: 18px;
}
#canvasWrapper {
  position: relative;
  font-size: 1rem;
}
#canvasWrapper::before {
  content: '';
  display: block;
  padding-top: 100%;
  height: 0;
}
#canvasWrapper canvas {
  position: absolute;
  top: 0;
  left: 0;
}
const { ref, computed, watch, onMounted, nextTick } = Vue;

let mainAnimate = null;
let subAnimate = null;


const app = Vue.createApp({
  setup() {
    const props = {
      value: 75,
      width: 300,
      height: 300,
      padding: "1em",
      lineWidth: 10,
      lineColor: "#28C428",
      lineBgWidth: 10,
      lineBgColor: "#f1f1f1",
      lineCap: "round",
      duration: 1000,
      delay: 0,
      labelVisible: true,
      labelColor: "#171717",
      labelSize: "2rem",
      labelLineHeight: 1.5,
      labelFont: "NotoSansKR",
      labelFormatter: (values) => values.map((v) => v + "%").join("\n"),
      subVisible: true,
      subValue: 60,
      subPadding: "1em",
      subLineColor: "#eba703",
      subLineWidth: 10,
      subLineBgColor: "#f1f1f1",
      subLineBgWidth: 10,
      onClick: (event) => {},
    };

    const canvasRef = ref(null);
    const isMounted = ref(false);

    const propsPadding = computed(() =>
      isMounted.value ? convertToPixels(props.padding) : 0
    );
    const propsLineWidth = computed(() =>
      isMounted.value ? convertToPixels(props.lineWidth) : 0
    );
    const propsLineBgWidth = computed(() =>
      isMounted.value ? convertToPixels(props.lineBgWidth) : 0
    );
    const propsSubLineWidth = computed(() =>
      isMounted.value ? convertToPixels(props.subLineWidth) : 0
    );
    const propsSubLineBgWidth = computed(() =>
      isMounted.value ? convertToPixels(props.subLineBgWidth) : 0
    );
    const propsSubPadding = computed(() =>
      isMounted.value ? convertToPixels(props.subPadding) : 0
    );
    const propsLabelSize = computed(() =>
      isMounted.value ? convertToPixels(props.labelSize) : 0
    );

    const convertToPixels = (input) => {
      if (!isNaN(input)) {
        return input;
      } else if (input.endsWith("px")) {
        return parseInt(input);
      } else if (input.endsWith("rem")) {
        return (
          parseInt(input) *
          parseFloat(getComputedStyle(document.documentElement).fontSize)
        );
      } else if (input.endsWith("em")) {
        return (
          parseInt(input) *
          parseFloat(getComputedStyle(canvasRef.value).fontSize)
        );
      } else if (input.endsWith("%")) {
        return (
          (parseInt(input) / 100) *
          parseFloat(getComputedStyle(canvasRef.value).fontSize)
        );
      }
    };

    const arcSize = computed(
      () => canvasRef.value.width / 2 - propsPadding.value
    );
    const subArcSize = computed(
      () =>
        arcSize.value -
          Math.max(propsLineWidth.value, propsLineBgWidth.value) -
          propsSubPadding.value || 0
    );
    let ctx = null;

    const drawingChartBg = () => {
      const canvas = canvasRef.value;
      ctx.beginPath();
      ctx.strokeStyle = props.lineBgColor;
      ctx.lineWidth = propsLineBgWidth.value;
      ctx.arc(
        canvas.width / 2,
        canvas.height / 2,
        arcSize.value,
        0,
        Math.PI * 2,
        false
      );
      ctx.stroke();
    };

    const drawingChart = (currentValue) => {
      const canvas = canvasRef.value;
      const radians = (currentValue / 100) * Math.PI * 2;
      ctx.beginPath();
      ctx.strokeStyle = props.lineColor;
      ctx.lineWidth = propsLineWidth.value;
      ctx.lineCap = "round";
      ctx.arc(
        canvas.width / 2,
        canvas.height / 2,
        arcSize.value,
        0 - (90 * Math.PI) / 180,
        radians - (90 * Math.PI) / 180,
        false
      );
      ctx.stroke();
    };

    const drawingSubChartBg = () => {
      const canvas = canvasRef.value;
      ctx.beginPath();
      ctx.strokeStyle = props.subLineBgColor;
      ctx.lineWidth = propsSubLineBgWidth.value;
      ctx.arc(
        canvas.width / 2,
        canvas.height / 2,
        subArcSize.value,
        Math.PI * 2,
        false
      );
      ctx.stroke();
    };

    const drawingSubChart = (subCurrentValue) => {
      const canvas = canvasRef.value;
      const radians = (subCurrentValue / 100) * Math.PI * 2;
      ctx.beginPath();
      ctx.strokeStyle = props.subLineColor;
      ctx.lineWidth = propsSubLineWidth.value;
      ctx.lineCap = "round";
      ctx.arc(
        canvas.width / 2,
        canvas.height / 2,
        subArcSize.value,
        0 - (90 * Math.PI) / 180,
        radians - (90 * Math.PI) / 180,
        false
      );
      ctx.stroke();
    };

    const drawingLabel = () => {
      const canvas = canvasRef.value;
      let labelCircleRadius =
        canvas.width / 2 -
        propsPadding.value -
        Math.max(propsLineWidth.value, propsLineBgWidth.value);
      if (props.subVisible) {
        labelCircleRadius -=
          propsSubPadding.value +
          Math.max(propsSubLineWidth.value, propsSubLineBgWidth.value);
      }
      clearCircle(ctx, canvas.width / 2, canvas.height / 2, labelCircleRadius);
      const fontSize = isNaN(propsLabelSize.value)
        ? propsLabelSize.value
        : propsLabelSize.value + "px";
      ctx.fillStyle = props.labelColor;
      ctx.font = `${fontSize} ${props.labelFont}`;
      const labels = props.subVisible
        ? [props.value, props.subValue]
        : [props.value];
      const lines = props
        .labelFormatter(labels.map((v) => Math.floor(v)))
        .split("\n");
      lines.forEach(function (line, index) {
        const textWidth = ctx.measureText(line).width;
        const textHeight = ctx.measureText(line).hangingBaseline;
        const left = (canvas.width - textWidth) / 2;
        const top =
          canvas.height / 2 + props.labelLineHeight * textHeight * index;
        ctx.fillStyle = "#f5f6fa";
        ctx.fillStyle = props.labelColor;
        ctx.fillText(line, left, top);
      });
    };

    const clearCircle = (context, x, y, radius) => {
      context.save();
      context.beginPath();
      context.arc(x, y, radius, 0, Math.PI * 2, true);
      context.clip();
      context.clearRect(x - radius, y - radius, radius * 2, radius * 2);
      context.restore();
    };

    const resetCanvas = () => {
      cancelAnimationFrame(mainAnimate);
      cancelAnimationFrame(subAnimate);
      const canvas = canvasRef.value;
      canvas.width = canvas.parentNode.clientWidth;
      canvas.height = canvas.parentNode.clientHeight;
      ctx = canvas.getContext("2d");
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.beginPath();
      ctx.strokeStyle = props.lineBgColor;
      ctx.lineWidth = propsLineBgWidth.value;
      ctx.arc(
        canvas.width / 2,
        canvas.height / 2,
        arcSize.value,
        0,
        Math.PI * 2,
        false
      );
      ctx.stroke();
      drawingChartBg();
      if (props.subVisible) {
        drawingSubChartBg();
      }
    };

    const runDraw = (currentValues = [0, 0]) => {
      const startTime = Date.now();
      const animate = () => {
        const currentTime = Date.now();
        const elapsedTime = currentTime - startTime;
        const regTime = Math.min(1, elapsedTime / props.duration);
        const easedT = easingFunctions.easeInOutQuad(regTime);
        drawingChart((props.value - currentValues[0]) * easedT);
        if (props.subVisible) {
          drawingSubChart((props.subValue - currentValues[1]) * easedT);
        }
        if (props.labelVisible) {
          drawingLabel();
        }
        if (regTime < 1) {
          mainAnimate = requestAnimationFrame(animate);
        }
      };
      animate();
    };

    const lazyRunSub = (currentValue = 0) => {
      drawingSubChartBg();
      const startTime = Date.now();
      const animate = () => {
        const currentTime = Date.now();
        const elapsedTime = currentTime - startTime;
        const regTime = Math.min(1, elapsedTime / props.duration);
        const easedT = easingFunctions.easeInOutQuad(regTime);
        drawingSubChart((props.subValue - currentValue) * easedT);
        if (props.labelVisible) {
          drawingLabel();
        }
        if (regTime < 1) {
          subAnimate = requestAnimationFrame(animate);
        }
      };
      animate();
    };

    const easingFunctions = {
      linear: (t) => t,
      easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
      easeInOutCubic: (t) =>
        t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
      easeInOutExpo: (t) =>
        t === 0
          ? 0
          : t === 1
          ? 1
          : t < 0.5
          ? Math.pow(2, 20 * t - 10) / 2
          : (2 - Math.pow(2, -20 * t + 10)) / 2,
      easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
    };

    onMounted(() => {
      nextTick(() => {
        isMounted.value = true;
      });
    });

    watch(
      () => isMounted.value,
      () => {
        if (isMounted.value) {
          resetCanvas();
          setTimeout(() => {
            runDraw();
          }, props.delay);
        }
      }
    );

    watch(
      () => props.subVisible,
      () => {
        if (props.subVisible) {
          lazyRunSub();
        } else {
          resetCanvas();
          setTimeout(() => {
            runDraw();
          }, props.delay);
        }
      },
      { deep: true }
    );

    return {
      props,
      canvasRef,
      isMounted,
      arcSize,
      subArcSize,
      convertToPixels,
      drawingChartBg,
      drawingChart,
      drawingSubChartBg,
      drawingSubChart,
      drawingLabel,
    };
  },
});

app.mount("#app");

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/vue/3.2.37/vue.global.prod.min.js