-
-
Save nathanlesage/7c56502a1ebb23560d0a861397a5a518 to your computer and use it in GitHub Desktop.
RingIndicator Vue Component
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <template> | |
| <!-- | |
| RingIndicator.vue Component | |
| Copyright (2025) by Hendrik Erz. | |
| This component is made available under the terms of the AGPL v3 only license. | |
| You are free to use, share, and adapt this component, but you must make any | |
| changes available under a compatible license. | |
| This component has been open-sourced as a companion to a guide article, | |
| published at https://www.hendrik-erz.de/post/guide-programmatically-draw-segmented-circles-or-ring-indicators-with-svg | |
| --> | |
| <svg | |
| class="progress-ring" | |
| v-bind:width="circleSize" | |
| v-bind:height="circleSize" | |
| v-bind:view-box="`0 0 ${circleSize} ${circleSize}`" | |
| > | |
| <g v-for="ring, ringIdx in props.rings"> | |
| <g> | |
| <title v-if="ring.label">{{ ring.label }}</title> | |
| <path | |
| :d="arcForCircle(ringIdx)" | |
| v-bind:stroke="ringColor(ringIdx)" | |
| fill="transparent" | |
| v-bind:stroke-width="lineWidth" | |
| /> | |
| </g> | |
| <g v-for="segment, idx in ring.segments"> | |
| <title>{{ segment.label }}</title> | |
| <path | |
| :d="arcForSegment(ringIdx, idx)" | |
| v-bind:stroke="segment.color" | |
| fill="transparent" | |
| v-bind:stroke-width="lineWidth" | |
| /> | |
| </g> | |
| </g> | |
| </svg> | |
| </template> | |
| <script setup lang="ts"> | |
| import { computed } from 'vue' | |
| export interface RingSegment { | |
| ratio: number | |
| color: string | |
| label?: string | |
| } | |
| export interface RingData { | |
| segments: RingSegment[] | |
| label?: string | |
| baseRingColor?: string | |
| } | |
| const MAX_RAD = 2 * Math.PI | |
| const props = defineProps<{ | |
| rings: RingData[] | |
| /** | |
| * How large should the SVG be? | |
| */ | |
| circleSize: number | |
| /** | |
| * How large should the lines of the rings be (default: 10% of the circle size)? | |
| */ | |
| lineWidth?: number, | |
| /** | |
| * The gap at the bottom of the ring(s), in percent of the circle. | |
| */ | |
| gap?: number | |
| }>() | |
| const circleGap = computed(() => Math.min(props.gap ?? 0.1, 1) * MAX_RAD) | |
| const circleSize = computed(() => props.circleSize ?? 20) | |
| const lineWidth = computed(() => props.lineWidth ?? (circleSize.value * 0.1)) | |
| function ringColor (ringIdx: number) { | |
| return props.rings[ringIdx].baseRingColor ?? '#cccccc' | |
| } | |
| function circleRadius (ringIdx: number) { | |
| const totalAvailableSpace = circleSize.value / 2 | |
| const ringCount = props.rings.length | |
| const spacePerRing = totalAvailableSpace / ringCount | |
| const radiusForRing = totalAvailableSpace - (ringIdx * spacePerRing) - (lineWidth.value / 2) | |
| return radiusForRing | |
| } | |
| function segmentOffset (ringIdx: number, idx: number): number { | |
| const segments = props.rings[ringIdx].segments | |
| const prevRatios = segments.slice(0, idx).reduce((prev, cur) => prev + cur.ratio, 0) | |
| const availableTheta = MAX_RAD - circleGap.value | |
| const prevPathTheta = prevRatios * availableTheta | |
| return prevPathTheta | |
| } | |
| function arcForSegment (ringIdx: number, idx: number): string { | |
| const ratio = props.rings[ringIdx].segments[idx].ratio | |
| const r = circleRadius(ringIdx) | |
| const thetaOffset = segmentOffset(ringIdx, idx) | |
| const startOffset = circleGap.value/2 | |
| const rot = 0 | |
| const availableTheta = MAX_RAD - circleGap.value | |
| const offset = startOffset + thetaOffset | |
| const theta0 = MAX_RAD * 0.75 - offset | |
| const theta1 = theta0 - availableTheta * ratio | |
| const translate = props.circleSize / 2 | |
| const x1 = Math.cos(theta0) * r + translate | |
| const y1 = -Math.sin(theta0) * r + translate | |
| const x2 = Math.cos(theta1) * r + translate | |
| const y2 = -Math.sin(theta1) * r + translate | |
| const largeArc = theta0 - theta1 > Math.PI ? 1 : 0 | |
| return `M ${x1} ${y1} A ${r} ${r} ${rot} ${largeArc} 1 ${x2} ${y2}` | |
| } | |
| function arcForCircle (ringIdx: number): string { | |
| const r = circleRadius(ringIdx) | |
| const startOffset = circleGap.value / 2 | |
| const availableTheta = MAX_RAD - circleGap.value | |
| const offset = startOffset | |
| const theta0 = MAX_RAD * 0.75 - offset | |
| const theta1 = theta0 - availableTheta | |
| const translate = props.circleSize / 2 | |
| const x1 = Math.cos(theta0) * r + translate | |
| const y1 = -Math.sin(theta0) * r + translate | |
| const x2 = Math.cos(theta1) * r + translate | |
| const y2 = -Math.sin(theta1) * r + translate | |
| return `M ${x1} ${y1} A ${r} ${r} 0 1 1 ${x2} ${y2}` | |
| } | |
| </script> | |
| <style lang="css" scoped> | |
| svg path { | |
| filter: saturate(50%); | |
| transition: 0.5s filter ease; | |
| } | |
| svg:hover { | |
| path:not(:hover) { | |
| filter: saturate(50%); | |
| } | |
| path:hover { | |
| filter: saturate(100%); | |
| } | |
| } | |
| </style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment