📦 Built an SVG gauge chart component

This commit is contained in:
Alicia Sykes 2022-01-04 22:45:36 +00:00
parent f5c11b3dc6
commit a889c0e78a
1 changed files with 361 additions and 0 deletions

View File

@ -0,0 +1,361 @@
<template>
<div class="gauge">
<svg
v-if="height"
:viewBox="`0 0 ${RADIUS * 2} ${height}`" height="100%" width="100%"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<!-- Inner shadow for empty part of the gauge -->
<filter :id="`innershadow-${_uid}`">
<feFlood flood-color="#c7c6c6" />
<feComposite in2="SourceAlpha" operator="out" />
<feGaussianBlur stdDeviation="2" result="blur" />
<feComposite operator="atop" in2="SourceGraphic" />
</filter>
<!-- Gradient color for the full part of the gauge -->
<linearGradient
v-if="hasGradient"
:id="`gaugeGradient-${_uid}`"
>
<stop
v-for="(color, index) in gaugeColor"
:key="`${color.color}-${index}`"
:offset="`${color.offset}%`" :stop-color="color.color"
/>
</linearGradient>
<mask :id="`innerCircle-${_uid}`">
<!-- Mask to make sure only the part inside the circle is visible -->
<!-- RADIUS - 0.5 to avoid any weird display -->
<circle :r="RADIUS - 0.5" :cx="X_CENTER" :cy="Y_CENTER" fill="white" />
<!-- Mask to remove the inside of the gauge -->
<circle :r="innerRadius" :cx="X_CENTER" :cy="Y_CENTER" fill="black" />
<template v-if="separatorPaths">
<!-- Mask for each separator -->
<path
v-for="(separator, index) in separatorPaths"
:key="index"
:d="separator" fill="black"
/>
</template>
</mask>
</defs>
<g :mask="`url(#innerCircle-${_uid})`">
<!-- Draw a circle if the full gauge has a 360° angle, otherwise draw a path -->
<circle
v-if="isCircle"
:r="RADIUS" :cx="X_CENTER" :cy="Y_CENTER"
:fill="hasGradient ? `url(#gaugeGradient-${_uid})` : gaugeColor"
/>
<path
v-else
:d="basePath" :fill="hasGradient ? `url(#gaugeGradient-${_uid})` : gaugeColor"
/>
<!-- Draw a circle if the empty gauge has a 360° angle, otherwise draw a path -->
<circle
v-if="value === min && isCircle"
:r="RADIUS" :cx="X_CENTER" :cy="Y_CENTER"
:fill="baseColor"
/>
<path v-else :d="gaugePath" :fill="baseColor" :filter="`url(#innershadow-${_uid})`" />
</g>
<template v-if="scaleLines">
<!-- Display a line for each tick of the scale -->
<line
v-for="(line, index) in scaleLines"
:key="`${line.xE}-${index}`"
:x1="line.xS" :y1="line.yS" :x2="line.xE" :y2="line.yE"
stroke-width="1" :stroke="baseColor"
/>
</template>
<!-- Option for displaying content inside the gauge -->
<foreignObject x="0" y="0" width="100%" :height="height">
<slot />
</foreignObject>
</svg>
</div>
</template>
<script>
/** A gauge chart component for showing percentages
* Heavily inspired by vue-svg-gauge by @hellocomet
* See: https://github.com/hellocomet/vue-svg-gauge
*/
import ErrorHandler from '@/utils/ErrorHandler';
// Main radius of the gauge
const RADIUS = 100;
// Coordinates of the center based on the radius
const X_CENTER = 100;
const Y_CENTER = 100;
/* Turn polar coordinate to cartesians */
function polarToCartesian(radius, angle) {
const angleInRadians = (angle - 90) * (Math.PI / 180);
return {
x: X_CENTER + (radius * Math.cos(angleInRadians)),
y: Y_CENTER + (radius * Math.sin(angleInRadians)),
};
}
/* Describe a gauge path according */
function describePath(radius, startAngle, endAngle) {
const start = polarToCartesian(radius, endAngle);
const end = polarToCartesian(radius, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
const d = [
'M', start.x, start.y,
'A', radius, radius, 0, largeArcFlag, 0, end.x, end.y,
'L', X_CENTER, Y_CENTER,
].join(' ');
return d;
}
export default {
name: 'Gauge',
props: {
value: {
type: Number,
default: 70,
},
min: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 100,
},
startAngle: {
type: Number,
default: -90,
validator: (value) => {
if (value < -360 || value > 360) {
ErrorHandler('Gauge Chart - Expected prop "startAngle" to be between -360 and 360');
}
return true;
},
},
endAngle: {
type: Number,
default: 90,
validator: (value) => {
if (value < -360 || value > 360) {
ErrorHandler('Gauge Chart - Expected prop "endAngle" to be between -360 and 360');
}
return true;
},
},
/* Size of the inner radius between 0 and RADIUS. Closer to RADIUS, is thinner gauge */
innerRadius: {
type: Number,
default: 60,
validator: (value) => {
if (value < 0 || value > 100) {
ErrorHandler(`Gauge Chart - Expected prop "innerRadius" to be between 0 and ${RADIUS}`);
}
return true;
},
},
/* Separator step, will display a each min + (n * separatorStep), won't show if null */
separatorStep: {
type: Number,
default: 20,
validator: (value) => {
if (value !== null && value < 0) {
ErrorHandler('Gauge Chart - Expected prop "separatorStep" to be null or >= 0');
}
return true;
},
},
/* Separator Thickness, unit is in degree */
separatorThickness: {
type: Number,
default: 4,
},
/* Gauge color. Can be either string or array of objects (for gradient) */
gaugeColor: {
type: [Array, String],
default: () => ([
{ offset: 0, color: '#20e253' },
{ offset: 30, color: '#f6f000' },
{ offset: 60, color: '#fca016' },
{ offset: 90, color: '#f80363' },
]),
},
/* Color of the base of the gauge */
baseColor: {
type: String,
default: '#DDDDDD',
},
/* Scale interval, won't display any scall if 0 or `null` */
scaleInterval: {
type: Number,
default: 5,
validator: (value) => {
if (value !== null && value < 0) {
ErrorHandler('Gauge Chart - Expected prop "scaleInterval" to be null or >= 0');
}
return true;
},
},
/* Transition duration in ms */
transitionDuration: {
type: Number,
default: 1500,
},
},
data() {
return {
X_CENTER,
Y_CENTER,
RADIUS,
tweenedValue: this.min,
};
},
computed: {
/* Height of the viewbox */
height() {
const { endAngle, startAngle } = this;
const { y: yStart } = polarToCartesian(RADIUS, startAngle);
const { y: yEnd } = polarToCartesian(RADIUS, endAngle);
return Math.abs(endAngle) <= 180 && Math.abs(startAngle) <= 180
? Math.max(Y_CENTER, yStart, yEnd)
: RADIUS * 2;
},
/* SVG d property of the path of the base gauge (the colored one) */
basePath() {
const { startAngle, endAngle } = this;
return describePath(RADIUS, startAngle, endAngle);
},
/* SVG d property of the gauge based on current value, to hide inverse */
gaugePath() {
const { endAngle, getAngle, tweenedValue } = this;
return describePath(RADIUS, getAngle(tweenedValue), endAngle);
},
/* Total angle of the gauge */
totalAngle() {
const { startAngle, endAngle } = this;
return Math.abs(endAngle - startAngle);
},
/* True if the gauge is a full circle */
isCircle() {
return Math.abs(this.totalAngle) === 360;
},
/* If gauge color is array, return true so gradient can be used */
hasGradient() {
return Array.isArray(this.gaugeColor);
},
/* Array of the path of each separator */
separatorPaths() {
const {
separatorStep, getAngle, min, max, separatorThickness, isCircle,
} = this;
if (separatorStep > 0) {
const paths = [];
let i = isCircle ? min : min + separatorStep;
for (i; i < max; i += separatorStep) {
const angle = getAngle(i);
const halfAngle = separatorThickness / 2;
paths.push(describePath(RADIUS + 2, angle - halfAngle, angle + halfAngle));
}
return paths;
}
return null;
},
/* Array of line configuration for each scale */
scaleLines() {
const {
scaleInterval, isCircle, min, max, getAngle, innerRadius,
} = this;
if (scaleInterval > 0) {
const lines = [];
let i = isCircle ? min + scaleInterval : min;
for (i; i < max + scaleInterval; i += scaleInterval) {
const angle = getAngle(i);
const startCoordinate = polarToCartesian(innerRadius - 4, angle);
const endCoordinate = polarToCartesian(innerRadius - 8, angle);
lines.push({
xS: startCoordinate.x,
yS: startCoordinate.y,
xE: endCoordinate.x,
yE: endCoordinate.y,
});
}
return lines;
}
return null;
},
/* Generate a logarithmic scale for smooth animations */
logScale() {
const logScale = [];
for (let i = this.max; i > 1; i -= 1) logScale.push(Math.round(Math.log(i)));
return logScale;
},
},
watch: {
/* Update chats value with animation */
value(newValue) {
this.animateTo(newValue);
},
},
methods: {
/* Get an angle for a value */
getAngle(value) {
const {
min, max, startAngle, totalAngle,
} = this;
const totalValue = (max - min) || 1;
return ((value * totalAngle) / totalValue) + startAngle;
},
/* Increment the charts current value with logarithmic delays, until it equals new value */
animateTo(newValue) {
let currentValue = this.tweenedValue;
let indexCounter = 0; // Keeps track of number of moves
const forward = currentValue < newValue; // Direction
const moveOnePoint = () => {
currentValue = forward ? currentValue + 1 : currentValue - 1;
indexCounter += 1;
setTimeout(() => {
if ((forward && currentValue <= newValue) || (!forward && currentValue >= newValue)) {
this.tweenedValue = currentValue;
moveOnePoint();
}
}, this.logScale[indexCounter]);
};
moveOnePoint();
},
},
mounted() {
// Set initial value
this.animateTo(this.value);
},
};
</script>
<style lang="css">
.gauge {
width: 100%;
height: 100%;
}
</style>