mirror of https://github.com/Lissy93/dashy.git
📦 Built an SVG gauge chart component
This commit is contained in:
parent
f5c11b3dc6
commit
a889c0e78a
|
@ -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>
|
Loading…
Reference in New Issue