Skip to content

Instantly share code, notes, and snippets.

@AnoRebel
Created September 7, 2022 17:32
Show Gist options
  • Save AnoRebel/42e87c0ef3cafe5339474cc4b79b9e04 to your computer and use it in GitHub Desktop.
Save AnoRebel/42e87c0ef3cafe5339474cc4b79b9e04 to your computer and use it in GitHub Desktop.
Vue 3 SVG Gauge components remade from `https://github.com/hellocomet/vue-svg-gauge`
<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>
<!-- This puts an inner shadow on the empty part of 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>
<!-- Determine the gradient color on 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>
<!-- This allow to display html inside the svg -->
<foreignObject x="0" y="0" width="100%" :height="height">
<slot />
</foreignObject>
</svg>
</div>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { Tween, Easing, update } from "es6-tween";
import { get as _get } from "lodash-es";
const props = defineProps({
/**
* Gauge value
*/
value: {
type: Number,
default: 70,
},
/**
* Gauge min value
*/
min: {
type: Number,
default: 0,
},
/**
* Gauge max value
*/
max: {
type: Number,
default: 100,
},
/**
* Must be between -360 and 360
* startAngle MUST be inferior to endAngle
*/
startAngle: {
type: Number,
default: -90,
validator: value => {
if (value < -360 || value > 360) {
console.warn('GaugeChart - props "startAngle" must be between -360 and 360');
}
return true;
},
},
/**
* Must be between -360 and 360
* startAngle MUST be inferior to endAngle
*/
endAngle: {
type: Number,
default: 90,
validator: value => {
if (value < -360 || value > 360) {
console.warn('GaugeChart - props "endAngle" must be between -360 and 360');
}
return true;
},
},
/**
* Size of the inner radius between 0 and RADIUS
* The closer to RADIUS, the thinner the gauge will be
*/
innerRadius: {
type: Number,
default: 60,
validator: value => {
if (value < 0 || value > 100) {
console.warn(`GaugeChart - props "innerRadius" must be between 0 and 100`); // ${RADIUS}
}
return true;
},
},
/**
* Separator step, will display a separator each min + (n * separatorStep)
* Won't display any separator if 0 or null
*/
separatorStep: {
type: Number,
default: 10,
validator: value => {
if (value !== null && value < 0) {
console.warn('GaugeChart - props "separatorStep" must be null or >= 0');
}
return true;
},
},
/**
* Separator Thickness, unit is in degree
*/
separatorThickness: {
type: Number,
default: 4,
},
/**
* Gauge color. Can be :
* - a simple color if passed as a 'string'
* - a gradient if is an array of objects :
* { offset: percentage where the color starts, color: color to display }
*/
gaugeColor: {
type: [Array, String],
default: () => [
{ offset: 0, color: "#347AB0" },
{ offset: 100, color: "#8CDFAD" },
],
},
/**
* Color of the base of the gauge
*/
baseColor: {
type: String,
default: "#DDDDDD",
},
/**
* Animation easing option
* You can check the Tween.js doc here :
* https://github.com/tweenjs/tween.js/blob/master/docs/user_guide.md
*
* There are a few existing function gourped by equation they represent:
* Linear, Quadratic, Cubic, Quartic, Quintic, Sinusoidal, Exponential,
* Circular, Elastic, Back and Bounce
*
* And then by the easing type: In, Out and InOut.
* The syntaxe is : equation.easingType
*/
easing: {
type: String,
default: "Circular.Out",
},
/**
* Scale interval
* Won't display any scall if 0 or `null`
*/
scaleInterval: {
type: Number,
default: 5,
validator: value => {
if (value !== null && value < 0) {
console.warn('GaugeChart - props "scaleInterval" must be null or >= 0');
}
return true;
},
},
/**
* Transition duration in ms
*/
transitionDuration: {
type: Number,
default: 1500,
},
});
// Main radius of the gauge
const RADIUS = ref(100);
// Coordinates of the center based on the radius
const X_CENTER = ref(100);
const Y_CENTER = ref(100);
/**
* Tweened value for the animation of the gauge
* Starts at `min`
* @type {Number}
*/
const tweenedValue = ref(props.min);
/**
* Height of the viewbox calculated by getting
* - the lower y between the center and the start and end angle
* - (RADIUS * 2) if one of the angle is bigger than 180°
* @type {Number}
*/
const height = computed(() => {
const { y: yStart } = polarToCartesian(RADIUS.value, props.startAngle);
const { y: yEnd } = polarToCartesian(RADIUS.value, props.endAngle);
return Math.abs(props.endAngle) <= 180 && Math.abs(props.startAngle) <= 180
? Math.max(Y_CENTER.value, yStart, yEnd)
: RADIUS.value * 2;
});
/**
* d property of the path of the base gauge (the colored one)
* @type {String}
*/
const basePath = computed(() => {
return describePath(RADIUS.value, props.startAngle, props.endAngle);
});
/**
* d property of the gauge according to the value.
* This gauge will hide a part of the base gauge
* @type {String}
*/
const gaugePath = computed(() => {
return describePath(RADIUS.value, getAngle(tweenedValue.value), props.endAngle);
});
/**
* Total angle of the gauge
* @type {Number}
*/
const totalAngle = computed(() => {
return Math.abs(props.endAngle - props.startAngle);
});
/**
* True if the gauge is a full circle
* @type {Boolean}
*/
const isCircle = computed(() => {
return Math.abs(totalAngle.value) === 360;
});
/**
* True if the gaugeColor is an array
* Result in displaying a gradient instead of a simple color
* @type {Boolean}
*/
const hasGradient = computed(() => {
return Array.isArray(props.gaugeColor);
});
/**
* Array of the path of each separator
*/
const separatorPaths = computed(() => {
if (props.separatorStep > 0) {
const paths = [];
// If the gauge is a circle, this will add a separator at the start
let i = props.isCircle ? props.min : props.min + props.separatorStep;
for (i; i < props.max; i += props.separatorStep) {
const angle = getAngle(i);
const halfAngle = props.separatorThickness / 2;
paths.push(describePath(RADIUS.value + 2, angle - halfAngle, angle + halfAngle));
}
return paths;
}
return null;
});
/**
* Array of line configuration for each scale
*/
const scaleLines = computed(() => {
if (props.scaleInterval > 0) {
const lines = [];
// if gauge is a circle, remove the first scale
let i = props.isCircle ? props.min + props.scaleInterval : props.min;
for (i; i < props.max + props.scaleInterval; i += props.scaleInterval) {
const angle = getAngle(i);
const startCoordinate = polarToCartesian(props.innerRadius - 4, angle);
const endCoordinate = polarToCartesian(props.innerRadius - 8, angle);
lines.push({
xS: startCoordinate.x,
yS: startCoordinate.y,
xE: endCoordinate.x,
yE: endCoordinate.y,
});
}
return lines;
}
return null;
});
watch(
() => props.value,
(next, _) => {
/**
* Watch the value and tween it to make an animation
* If value < min, used value will be min
* If value > max, used value will be max
*/
let safeValue = next;
if (next < props.min) {
safeValue = props.min;
}
if (next > props.max) {
safeValue = props.max;
}
const animate = () => {
if (update()) {
requestAnimationFrame(animate);
}
};
new Tween({ tweeningValue: tweenedValue.value })
.to({ tweeningValue: safeValue }, props.transitionDuration)
.easing(_get(Easing, props.easing))
.onUpdate(object => {
tweenedValue.value = object.tweeningValue;
})
.start();
animate();
}
// { immediate: true }
);
/**
* Turn polar coordinate to cartesians
* @param {Number} centerX - abscisse of the center
* @param {Number} centerY - ordinate of the center
* @param {Number} radius - radius of the circle
* @param {Number} angle - angle in degres
* @returns {String} - d property of the path
*/
const polarToCartesian = (radius, angle) => {
const angleInRadians = ((angle - 90) * Math.PI) / 180;
return {
x: X_CENTER.value + radius * Math.cos(angleInRadians),
y: Y_CENTER.value + radius * Math.sin(angleInRadians),
};
};
/**
* Describe a gauge path according
* @param {Number} radius
* @param {Number} startAngle - in degre
* @param {Number} endAngle - in degre
* @returns {String} - d property of the path
*/
const 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.value,
Y_CENTER.value,
].join(" ");
return d;
};
/**
* Get an angle for a value
* @param {Number} value
* @returns {Number} angle - in degree
*/
const getAngle = value => {
// Make sure not to divide by 0
const totalValue = props.max - props.min || 1;
return (value * totalAngle.value) / totalValue + props.startAngle;
};
</script>
<style lang="scss">
.gauge {
width: 100%;
height: 100%;
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment