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