<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");
This Pen doesn't use any external CSS resources.