Gauge's for Dashboard 2.0 made with ui_template

Type linear, round, vertical, or artless.

Code is HERE

TEMPLATE:

<template>
    <div>
        <template v-if="type === 'round'">
            <div ref="hng" class="round-led-level" :style="`--size:${size}; --shadow:${shadow}; --ledsize:${ledSize};`">
                <header>
                    <div class="round-led-level-text">
                        <span class="round-led-level-label">{{label}}</span>
                    </div>
                </header>
                <div class="round-led-level-graph">
                    <div class="round-led-level-stripe" :class="{'led-level-flat': flat }">
                        <div v-for="(color, index) in colors" :key="index" class="round-led-level-led"
                            :ref="'dot-' + index">
                        </div>
                    </div>
                    <div class="round-led-level-centered-text">
                        <span class="round-led-level-value">{{formattedValue}}</span>
                        <span class="round-led-level-unit">{{unit}}</span>
                    </div>
                    <div class="round-led-level-limits">
                        <span>{{min}}</span>
                        <span>{{max}}</span>
                    </div>
                </div>
                <div>
        </template>
        <template v-if="type === 'linear'">
            <div ref="hng" class="led-level" :style="`--shadow:${shadow};`">
                <div class="led-level-text">
                    <span class="led-level-label">{{label}}</span>
                    <span class="led-level-value">{{formattedValue}}<span class="led-level-unit">{{unit}}</span></span>
                </div>
                <div class="led-level-stripe" :class="{'led-level-flat': flat }">
                    <div v-for="(color, index) in colors" :key="index" class="led-level-led" :ref="'dot-' + index">
                    </div>
                </div>
                <div class="led-level-limits">
                    <span>{{min}}</span>
                    <span>{{max}}</span>
                </div>
                <div>
        </template>
        <template v-if="type === 'vertical'">
            <div ref="hng" class="led-level-vertical" :style="`--shadow:${shadow}; --size:${size};`">
                <header>
                    <div class="round-led-level-text">
                        <span class="round-led-level-label">{{label}}</span>
                    </div>
                </header>
                <div class="led-level-vertical-content">
                    <div class="led-level-stripe" :class="{'led-level-flat': flat }">
                        <div v-for="(color, index) in colors" :key="index" class="led-level-led" :ref="'dot-' + index">
                        </div>
                    </div>
                    <div class="led-level-limits">
                        <span>{{max}}</span>
                        <span>{{min}}</span>
                    </div>
                    <div class="round-led-level-centered-text">
                        <span class="round-led-level-value">{{formattedValue}}</span>
                        <span class="round-led-level-unit">{{unit}}</span>
                    </div>
                </div>
            </div>

        </template>
        <template v-if="type === 'artless'">
            <div ref="hng" :class="icon ? 'ag-wrapper-2' : 'ag-wrapper-1'" :style="`--line-color:${colors[0]};`">
                <div v-if="icon" class="ag-icon">
                    <v-icon aria-hidden="false">{{icon}}</v-icon>
                </div>
                <div class="ag-content">
                    <div class="ag-text">
                        <span class="ag-label">{{label}}</span>
                        <span class="ag-value">{{formattedValue}}<span class="ag-unit">{{unit}}</span></span>
                    </div>
                    <div class="ag-track" ref="agLine">
                        <div class="ag-track-background"></div>
                        <div class="ag-track-foreground" :style="{'width': linesize +'%'}"></div>
                    </div>
                    <div class="ag-limits">
                        <span class="ag-min">{{min}}</span>
                        <span class="ag-max">{{max}}</span>
                    </div>
                </div>
            </div>
        </template>
    </div>
</template>



<script>
    export default {
    data(){
        return {
            //Define me here
            type:"artless", // Gauge type. "artless", "linear", "vertical" or "round"                                          
            label:"Artless", // The label
            icon:"mdi-account", // (type: artless) (optional) the icon
            zeroCross:false,// (type: artless) line changes color depending on value being positive or negative (at least 2 colors must be defined and min must be 0)
            min:0, // Smallest expected value
            max:100, // Highest expected value
            unit:"cm³",// The unit of the measurement
            dim:0.3, //(type: round, linear, vertical) How dim is led when not glowing
            shadow:0, //(type: round, linear, vertical) Led shadow intensity (too much makes graphics muddy, 0 removes shadows)
            filterFunction:"brightness", // (type: round, linear, vertical) "brightness" for dark themes, "opacity" for light themes  
            animate:true, // Animating led's is not most performant thing in the world.                          
            
            // Define colors

            // For type "round", "vertical" and "linear" the count of colors equals count of led's.
            // For type "artless" the line changes color based on percentage of value turned index of colors array.  
            // For type "round" the led size depends on how many colors is defined. About 20 is optimal.
            // Color can be defined as:
            // HEX - "#FF00FF" 
            // RGB - rgb(0,65,88)
            // named color - "red"
            // or depend on some defined CSS variable      
            colors:[
                    "#0fb60f",                   
                    "#0fb60f",                   
                    "#0fb60f",                   
                    "#0fb60f",                   
                    "#0fb60f",                   
                    "#0fb60f",                   
                    "#0fb60f",                   
                    "#0fb60f",                   
                    "#0fb60f",                   
                    "#0fb60f",                   
                    "#0fb60f",                   
                    "#0fb60f",                   
                    "#0fb60f",                   
                    "#0fb60f",                   
                    "red",
                    "red",
                    "red",
                    "red",
                   ],            
            
            //no need to change those
            value:0,
            previousValue:0,
            size:100,           
            inited:false
        }
    },


   
    methods: {        
        getElement: function(name,base){        
            if(base){
                return this.$refs[name]
            }
            return this.$refs[name][0]
        },
        validate(data){
            let ret
            if(typeof data !== "number"){
                ret = parseFloat(data)
                if(isNaN(ret)){
                    console.log("BAD DATA! gauge type:",this.type, "id:",this.id,"data:",data)
                    return null
                }   
            }
            else{
                ret = data
            }            
            return ret
        },

        full: function(){
            return Math.floor(this.colors.length*this.percentage/100)
        },
        half: function (){
            let p = this.colors.length*this.percentage/100;
            p -= this.full()
            p *= .5
            p += this.dim;
            return p;
        },
        filter: function(amount){
            let f
            switch(amount){
                case "full":{
                    f = this.filterFunction == "brightness" ? "brightness(1.1)" : "opacity(1)";
                    break
                }
                case "half":{
                    f = this.filterFunction == "brightness" ? "brightness(" +this.half()+")" : "opacity(" +this.half()+")";
                    break
                }
                default:{
                    f = this.filterFunction == "brightness" ? "brightness(" +this.dim+")"  : "opacity(" +this.dim+")";
                    break
                }
            }
            return f
        },

        lit: function(){
            if(this.inited == false){
                return
            }
            const down = this.previousValue > this.value

            let time = .01            
            this.colors.forEach((color,i) => {
                let dot = this.getElement("dot-"+i);
                if(!dot){
                    console.log("lit() no dots found")
                    return
                }                
                if(i<this.full()){
                    dot.style.filter=this.filter("full");                   
                }
                else if(i==this.full()){
                    dot.style.filter= this.filter("half");                   
                }
                else{
                    dot.style.filter= this.filter("dim");                   
                }
                if(down){
                    time = (this.colors.length - i) * .12                    
                }else{
                    time = i * 0.08
                }
                dot.style.transition = this.animate ? "filter "+time+"s" : "unset";
            })
            this.previousValue = this.value
        },
        changeLine:function(){
            const line = this.getElement("agLine",true);
            if(!line){
                console.log("no line found")
                return            
            }
           
            let c = Math.floor(this.colors.length * this.percentage / 100)
            if(c >= this.colors.length){
                c = this.colors.length - 1
            }
            if(c < 0){
                c = 0
            }
            if(this.zeroCross){
                c = this.value > 0 ? 1 : 0
            }
            line.style.setProperty('--line-color',this.colors[c])

        },
        onResize:function(){            
            let g = this.getElement("hng",true)
            if(!g){
                return
            }           
            this.$nextTick(() => {
                let last = this.size 
                let changed = this.type == "vertical" ? g.clientHeight : g.clientWidth;   
                if(Math.abs(last - changed) < 3){
                    return
                }
                this.size = changed
                g.style.setProperty('--size',this.size);            
                if(this.type == "round"){
                    this.updateLayout()
                }
            })
                              
        },
        updateLayout:function(){
            let angle;
            const step = 270 / this.colors.length;
            const radius = (this.size - (this.size*0.1))/2
            const s = this.ledSize / -2;
            const outline = this.filterFunction == "opacity" ? "black" : "white";        
            this.colors.forEach((c,i) => {
                    let dot = this.getElement("dot-"+i);
                    if(!dot){
                        console.log("round init()  no dots found")
                        return
                    }
                    dot.style.backgroundColor = c
                    dot.style.outlineColor="color-mix(in srgb, "+c+", "+outline+" 35%)";
                    dot.style.transition = "filter 0.1s";
                    dot.style.setProperty('--dot',i);
                    angle = ((i+1)*step) * Math.PI / 180;
                    dot.style.left = s + radius * Math.cos(angle) + 'px';
                    dot.style.top = s + radius * Math.sin(angle) + 'px';
                    dot.style.transform = 'translate('+s+'px, '+s+'px)'; 
                    dot.style.rotate = (angle - 0.08)+"rad"               
                }
            )
        }
    },
    watch: {
        msg: function(){    
            if(this.msg.payload !== undefined){  
                const v = this.validate(this.msg.payload)                
                if(v === null){
                    return
                }         
                this.value = v
                if(this.type != "artless"){
                    this.lit()
                }
                else{
                    this.changeLine()
                } 
            }
        }
    },
    computed: {
        formattedValue: function () {
            return this.value.toFixed(2)
        },
        percentage: function(){
            return Math.floor(((this.value - this.min) / (this.max - this.min)) * 100);
        },
        linesize:function(){
            if(this.zeroCross){
                return Math.floor(((Math.abs(this.value) - this.min) / (this.max - this.min)) * 100);           
            }
            else{
                return Math.max(0,this.percentage)
            }
        },                
        ledSize:function(){
            const s = 4.71239 * ((this.size - (this.size*0.3))/2)            
            return s / this.colors.length
        },
        flat:function(){
            return this.shadow == 0
        }

    },
    mounted(){
        const outline = this.filterFunction == "opacity" ? "black" : "white";
        if(this.type == "round"){
            let g = this.getElement("hng",true)
            if(!g){
                return
            }            
            this.resizeObserver = new ResizeObserver((entries) => {
                this.onResize()
            });
            this.resizeObserver.observe(g);           
            
            setTimeout(()=>{
                this.onResize()
            },20)
        }
        else if(this.type == "linear"){
            
            this.colors.forEach((c,i) => {
                    let dot = this.getElement("dot-"+i);
                    if(!dot){
                        console.log("linear init() no dots found")
                        return
                    }
                    dot.style.outlineColor="color-mix(in srgb, "+c+", "+outline+" 35%)";
                    dot.style.backgroundColor = c               
                    dot.style.transition = "filter 0.1s";
                }
            )
        }
        else if(this.type == "vertical"){
            let g = this.getElement("hng",true)
            if(!g){
                return
            }
            this.resizeObserver = new ResizeObserver((entries) => {
                    this.onResize()
                });
                this.resizeObserver.observe(g);                
                setTimeout(()=>{
                this.onResize()
            },20)
            this.colors.forEach((c,i) => {
                let dot = this.getElement("dot-"+i);
                if(!dot){
                    console.log("linear init() no dots found")
                    return
                }
                dot.style.outlineColor="color-mix(in srgb, "+c+", "+outline+" 35%)";
                dot.style.backgroundColor = c
                dot.style.transition = "filter 0.1s";
            })
        }
        else if(this.type == "artless"){
            const line = this.getElement("agLine",true);
            line.style.setProperty('--line-color',this.colors[0])
            if(this.animate == true){                
                if(!line){
                    console.log("artless init() no line found")
                    return
                }
                line.style.transition = "width 0.5s";
            }
        }        
       
        this.inited = true;
    },
    unmounted () {
        if(this.resizeObserver){
            this.resizeObserver.disconnect();
            this.resizeObserver = null;            
        }
    }
}
</script>

CSS:

.led-level{
    display: grid;
    grid-template-rows: 1.3em minmax(3px, 1fr) .7em;
    gap: 2px;
    height: 100%;
}
.led-level-stripe{
    display: flex;
    gap:2px;
}    
.led-level-led {
    --s:var(--shadow,0.2);
    --shadowColor:rgba(0,0,0,var(--s));
    background: #ffffff;
    width: 100%;
    height: 100%;
    outline:1px solid;
    outline-offset:-1px;
    border-radius: 0px;
    box-shadow: inset 0px 0px 10px 0px var(--shadowColor), 0px 0px 3px 0px var(--shadowColor);
    filter: brightness(0.4);
}
.led-level-text{
    font-size: 1.25em;
    line-height: 1em;
    align-self: end;
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    user-select: none;
}
.led-level-value{       
    font-weight:bold;
}
.led-level-unit{
    font-size:.75em;
    font-weight:normal;
    padding-inline-start: 0.15em;
}
.led-level-limits{
    display: flex;
    justify-content: space-between;
    font-size: .75em;
    line-height: .75em;
    align-content: flex-end;
    flex-wrap: wrap;
     user-select: none;
}
.round-led-level, .led-level-vertical{
    display: grid;
    grid-template-rows: 1em 1fr;
    width:100%;
    height: 100%;
    aspect-ratio: 1/1;
    position: relative;
    margin: auto;
}
.round-led-level-graph{
    position: relative;
    aspect-ratio: 1;
}
.round-led-level-stripe{
    display: block;
    position: absolute;
    left: 50%;
    top: 56%;
    rotate: 135deg;  
}    
.round-led-level-led {
    --s:var(--shadow,0.2);
    --shadowColor:rgba(0,0,0,var(--s));
    background: #ffffff;
    position: absolute;
    width: calc(var(--ledsize) * 1px);
    aspect-ratio: 1/1;
    border-radius: 4px;
    outline:1px solid;
    outline-offset:-1px;
    box-shadow: inset 0px 0px calc(var(--ledsize) / 3 * 1px) 0px var(--shadowColor), 0px 0px calc(var(--ledsize) / 7 * 1px) 0px var(--shadowColor);
    filter: brightness(0.4);
    transform-origin: center center;
}
.round-led-level-text{    
    font-size: clamp(0.5em,calc(var(--size) * .1 * 1px),1.25em);
    line-height: 1rem;
    text-align: center;
    user-select: none;
    white-space: nowrap;
}
.round-led-level-centered-text{
    position: absolute;
    inset: 0;
    font-size: 1rem;
    line-height: 1;
    display: grid;
    text-align: center;
    grid-template-rows: 1.5fr 1fr;
    gap: 0.1em;
    user-select: none;   
    align-items: center;
}
.round-led-level-value{       
    font-weight:bold;
    font-size: calc(var(--size) * .15 * 1px);
    align-self: end;
}
.round-led-level-unit{
    font-size:calc(var(--size) * .1 * 1px);
    font-weight:normal;
    align-self: start;
    padding-inline-start: 0.15em;
}
.round-led-level-limits{
    position: absolute;
    inset:0; 
    display: flex;
    justify-content: space-between;
    font-size: calc(var(--size) * .06 * 1px);
    line-height: calc(var(--size) * .06 * 1px);
    align-content: flex-end;
    flex-wrap: wrap;
    padding-inline:1em;
    user-select: none;
}
.ag-wrapper-2 {
    display: grid;
    grid-template-columns: 3em 1fr;
    gap:1em;
}
.ag-wrapper-1 {
    display: grid;
    grid-template-columns: 1fr;   
}
.ag-icon{
    font-size: 2em;
    display: flex;
    flex-direction: column;
    justify-content: center;        
}
.ag-content{
    display: grid;
    grid-template-rows: 1fr 7px 0.75em;
    gap: 2px;
}
.ag-text{
    font-size: 1.25em;
    line-height: 1em;
    align-self: end;
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    user-select: none;
}
.ag-value{       
    font-weight:bold;
}
.ag-unit{
    font-size:.75em;
    font-weight:normal;
    padding-inline-start: 0.15em;
}
.ag-limits{
    display: flex;
    justify-content: space-between;
    font-size: .75em;
    line-height: .75em;
    align-content: center;
    flex-wrap: wrap;
    user-select: none;
}

.ag-track{
    position:relative;
    display:flex;
    align-items: center;
    width: 100%;
    border-radius: 6px;
}

.ag-track-background{
    position:absolute;
    background: var(--line-color,rgb(var(--v-theme-primary)));
    opacity: 0.45;
    width: 100%;
    height: 50%;
    border-radius:inherit;
}
.ag-track-foreground{
    position:absolute;
    background-color: var(--line-color,rgb(var(--v-theme-primary)));
    width: 50%;
    height: 100%;
    max-width: 100%;
    border-radius:inherit;
    transition:inherit;
}
.led-level-vertical{
    gap:calc(var(--size) * .1 * 1px);
    aspect-ratio: var(--aspect-ratio);
}
.led-level-vertical-content{
    display:grid;
    grid-template-columns: 1fr auto 4fr;
}
.led-level-vertical .round-led-level-centered-text{
    grid-template-rows: 2.5fr 1fr;
    padding-inline-start: 1em;
}
.led-level-vertical .round-led-level-value{
    font-size: calc(var(--size) * .2 * 1px);
}
.led-level-vertical .led-level-stripe{
    flex-direction: column-reverse;
}
.led-level-vertical .led-level-limits{
   display: flex;
    justify-content: space-between;
    font-size: calc(var(--size) * .075 * 1px);
    line-height: calc(var(--size) * .075 * 1px);
    align-content: flex-start;
    align-items: start;
    flex-wrap: wrap;
    user-select: none;
    flex-direction: column;
    padding-inline-start: 0.4em;
}
.led-level-flat div{
    box-shadow: unset;
}

13 Likes

Compass

Code is HERE
<template>
    <div class="windrose" v-resize="onResize">
        <svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0" y="0" width="100%" height="100%" viewBox="0 0 500 500">
            <g>
                <circle cx="250" cy="250" r="205" class="ring" />
                <circle cx="250" cy="250" r="205" class="tick major" pathLength="720"/>
                <circle cx="250" cy="250" r="200" class="tick mid" pathLength="720"/>
                <circle cx="250" cy="250" r="195" class="tick minor" pathLength="720"/>                
            </g>
            <g>
                <text transform="matrix(1 0 0 1 385 268)" class="letters">E</text>
                <text transform="matrix(1 0 0 1 233 420)" class="letters">S</text>
                <text transform="matrix(1 0 0 1 233 112)" class="letters">N</text>
                <text transform="matrix(1 0 0 1 82 268)" class="letters">W</text>
            </g>
        </svg>
        <div class="txt">
            <div>{{formattedDirection}}</div>
            <div class="small">DIRECTION</div>
            <div class="small">SPEED</div>
            <div>{{formattedSpeed}}</div>
        </div>
        <div ref="needle" class="needle" :style="{'rotate': direction +'deg'}"></div>
    </div>
</template>

<script>
    export default {       
        data() {           
            return {              
               direction:0,
               speed:0,
               timeout:null
            }
        },
        watch: {
            msg: function(){           
                if(this.msg?.payload != undefined){
                    if(this.msg.payload.direction != undefined){
                        this.direction = this.shortWayRotation(this.direction, this.msg.payload.direction)
                        if(this.msg.speed == undefined){
                            this.msg.speed = this.speed
                        }                                            
                    }
                    if(this.msg.payload.speed != undefined){
                        this.speed = this.msg.payload.speed 
                        if(this.msg.payload.direction == undefined){
                            this.msg.payload.direction = this.direction;
                        }                                          
                    }
                    this.send({payload:this.msg.payload})
                }
            }
        }
        ,
        computed: {
            formattedSpeed:function(){
                return this.speed + "m/s" 
            },
            formattedDirection:function(){
                let r = (this.direction + 360) % 360;
                if(r<0){
                    r += 360;
                }
                return r + "°"
            }

        },
        methods: {

            onResize(){
                console.log('this',this)
            },
            
            shortWayRotation:function(frm, to){               
                let delta = ((((to - frm) % 360) + 540) % 360) - 180;
                return (frm + delta);
            },
            manageAnimations:function(event){
           
                if(document.hidden){                   
                    if(this.$refs.needle){                        
                        this.$refs.needle.style.transition = "rotate 0s"
                    }
                }
                else{                   
                    if(this.$refs.needle){
                        if(this.timeout){
                            clearTimeout(this.timeout)
                            this.timeout = null
                        }
                        this.timeout = setTimeout(()=>{
                            this.$refs.needle.style.transition = "rotate .5s"
                        },40)

                        
                    } 
                }
            }
            
            
        },        
        mounted() {
            document.addEventListener("visibilitychange", this.manageAnimations);
            window.addEventListener("blur", this.manageAnimations);
            window.addEventListener("focus", this.manageAnimations);      
            
        },
        
        unmounted() {
            if(this.timeout){
                clearTimeout(this.timeout)
                this.timeout = null
            }
            document.removeEventListener("visibilitychange", this.manageAnimations);
            window.removeEventListener("blur", this.manageAnimations);
            window.removeEventListener("focus", this.manageAnimations);
        }
    }
</script>
<style>    
    div:has(>.windrose){
        align-items: center;
    }
    .windrose{
        position:relative;
        width:100%;       
        aspect-ratio: 1;
        container-type: inline-size;
    }
    .tick {
        fill: none;
        stroke: currentColor;       
    }
    .major{
        stroke-dasharray:1 89;
        stroke-dashoffset:0.5;
        stroke-width:40px;
    }
    .mid{
        stroke-dasharray:0 45 1 44 0 45 1 44;
        stroke-dashoffset:0.5;
        stroke-width:30px;
    }
    .minor{
        stroke-dasharray:0 5 1 4 1 4 1 4 1 4 1 4 1 4 1 4 1 4;
        stroke-dashoffset:0.5;
        stroke-width:20px;
    }
    .letters {
        font-size: 48px;
        fill:currentColor;
    }
    .ring{
        fill:none;
        stroke:currentColor;
        stroke-width:40;
        opacity:0.1;
    }
    .needle{
        position: absolute;
        inset: 0;
        left: calc(50% - 1px);
        width: 2px;
        height: 100%;
        transform-origin: center;
        transition: rotate .5s;
    }
    
    .needle:before, .needle:after{
        content: "▼";
        color: red;
        position: absolute;
        top: 1%;
        text-align: center;
        font-size: clamp(0.5rem, 10cqi, 2rem);
        transform: translateX(calc(-50% + 1px));
    }
    .needle:before{
        color:currentColor;
        font-size: calc(clamp(0.5rem, 10cqi, 2rem) + 4px);
    }
    .txt{
        position:absolute;
        inset:0;
        display: grid;
        place-content: center;
        width: 100%;
        text-align: center;        
        font-size: clamp(0.5rem, 12cqi, 3rem);
        font-weight:700;
        line-height:1.2em;
    }
    .txt .small{
        font-size: clamp(0.2rem, 6cqi, 1.5rem);
        line-height: 1em;
        font-weight:500;
    }
</style>

image

The payload for compass must be combined to have speed and direction properties. (previously was topic = "direction") This is changed to have porper wakeup on replayMessage.

10 Likes

And as already got questions how to configure - this part of code needs to be found in template

2 Likes

For what it's worth, trying to use the vertical style resulted in a no-show for the gauge, I needed to add a min-height here at the end of the CSS:

.led-level-flat div {
    box-shadow: unset;
    min-height: 4px;
}

But have you tried to give it a bit more vertical space to live in?
image

Cos if something defines itself to be vertical...

1 Like

Couldn't sleep this night too well, spent couple of night hours...

image

Code is HERE
<script>
    export default{
        data(){
            return {
                //define me here
                min:0,
                max:100,
                label:"Gauge",
                measurement:"Temperature",
                unit:"°C",
                majorDivision:10, // number of input units for each (numbered) major division
                minorDivision: 2, // number of input units for each minor division
                sectors:[{start:0,end:10,color:"skyblue"},{start:70,end:90,color:"#e99200"},{start:90,end:100,color:"red"}],
                valueDecimalPlaces: 1,
                majorDecimalPlaces:0,
                allowOverflow:true, // needle can travel over scale boundaries
                showUpdate:true,
                updateLabel:"Last update:",               
                
                //no need to change
                value:0,
                updateTime:0               
            }
        }
    }
</script>
<template>
    <div class="hn-sng">
        <div class="label">{{label}}</div>
        <svg ref="hn-gauge" width="100%" height="100%" viewBox="0 0 100 100">
            <g>
                <path v-for="(item, index) in sectors" :key="index" :ref="'sector-' + index" class="sector"
                    stroke-width="5" d="M 10 90 A 47.5 47.5 25 1 1 90 90"></path>
            </g>
            <g>
                <path class="tick-minor" :style="{'stroke-dasharray':minor }" pathLength="492" stroke-width="5" d="M 10 90 A 47.5 47.5 25 1 1 90 90" ></path>
                <path ref="arc" class="tick-major" :style="{'stroke-dasharray':major }" pathLength="246" stroke-width="5" d="M 10 90 A 47.5 47.5 25 1 1 90 90"></path>
            </g>
            <g>
                <text v-for="(item, index) in numbers" :key="index" class="num" text-anchor="middle" y="-37"
                    :style="`rotate: ${item.r}deg;`">{{item.n}}</text>
            </g>
            <g>
                <text class="measurement" y="48" x="50%" text-anchor="middle">{{measurement}}</text>
                <text class="unit" y="75" x="50%" text-anchor="middle">{{unit}}</text>
                <text class="value" y="90" x="50%" text-anchor="middle">{{formattedValue}}</text>
                <text v-if="showUpdate" ref="update" class="update" y="100" x="0"
                    text-anchor="left">{{formattedUpdate}}</text>
            </g>
            <g ref="o-needle" class="o-needle">
                <path d="M 0,0 -1.5,0 -0.25,-43 0.25,-43 1.5,0 z"></path>
                <circle cx="0" cy="0" r="3"></circle>
            </g>
        </svg>
    </div>
</template>

<script>
    export default{ 
        methods:{
            getElement: function(name,base){
                if(base){
                    return this.$refs[name]
                }
                return this.$refs[name][0]
            },
            validate: function(data){
                let ret                
                if(typeof data !== "number"){
                    ret = parseFloat(data)
                    if(isNaN(ret)){
                        console.log("BAD DATA! gauge id:",this.id,"data:",data)
                        return null
                    }
                }                    
                else{
                    ret = data
                }                
                return ret
            },
            range:function (n, p, r) {           
                if (p.maxIn > p.minIn) {
                    n = Math.min(n, p.maxIn)
                    n = Math.max(n, p.minIn)
                } else {
                    n = Math.min(n, p.minIn)
                    n = Math.max(n, p.maxIn)
                }
                if(r){
                    return Math.round(((n - p.minIn) / (p.maxIn - p.minIn) * (p.maxOut - p.minOut)) + p.minOut);
                }
                return ((n - p.minIn) / (p.maxIn - p.minIn) * (p.maxOut - p.minOut)) + p.minOut;
            },
            generateNumbers:function(min,max){               
               
                let nums = []
                let t = (max - min) / this.divisors.major;
                let i = min;
                let ro = -123
                let rp = 246 / this.divisors.major
               
                for (let e = 0; e < this.divisors.major + 1; ++e){
                    if(this.min > this.max){
                        nums.unshift({n: i.toFixed(this.majorDecimalPlaces),r: ro + (e * rp) })
                    }
                    else{
                        nums.push({n: i.toFixed(this.majorDecimalPlaces),r: ro + (e * rp) })
                    }
                    
                    i = parseFloat(i + t);                   
                }
                return nums 
            },
            sectorData:function(full){  

                let ret = []
                this.sectors.forEach((sector,idx) => {
                    let sec = {name:'sector-'+idx,color:sector.color}
                    const params = {minIn:this.min, maxIn:this.max, minOut:0, maxOut:full}
                    const start = this.range(sector.start,params,false)
                    const end = this.range(sector.end,params,false)
                    const pos = Math.min(start, end)
                    const span = Math.max(start, end) - pos
                    sec.css = `0 ${pos} ${span} var(--dash)`
                    ret.push(sec)
                })
                return ret
            },
            rotation:function(v){
                const overflow = this.allowOverflow ? (this.max-this.min) * 0.1 : 0
                const maxRotation = this.allowOverflow ? (123 * 1.2) : 123
                const min = this.min - overflow
                const max = this.max + overflow
                const params = {minIn:min, maxIn:max, minOut:-maxRotation, maxOut:maxRotation};
                if (v === null) {
                    v = Math.min(min, max)
                }
                return this.range(v,params,false) +'deg'          
            }

        },
        watch: {
            msg: function(){
                if(this.msg.payload != undefined){
                    this.value = this.validate(this.msg.payload)
                    this.updateTime = Date.now()                    
                    this.getElement('o-needle',true).style.rotate = this.rotation(this.value)                    
                }
            }
        },
        computed: {            
            formattedValue: function () {
                return this.value !== null ? this.value.toFixed(this.valueDecimalPlaces) : "---"
            },
            formattedUpdate:function (){
                return this.updateLabel+" "+this.time
            },
            time:function(){
                return new Intl.DateTimeFormat('default', {hour: 'numeric', minute: 'numeric',second: 'numeric'}).format(this.updateTime);
            },
            numbers:function(){
                return this.generateNumbers(this.min,this.max)
            },
            major:function(){
                return "2," + ((246 / this.divisors.major)-2)           
            },
            minor:function(){
                let step = 492 / this.divisors.major / this.divisors.minor
                let line = "0," + step + ","
                step --
                for(let i=0;i<this.divisors.minor-1;++i){
                    line += "1,"+step+","
                }
                line = line.slice(0, -1);

                return line
            },
            offset:function(){
                return -1
            },
            divisors:function(){
                let span
                if(this.max > this.min){
                    span = this.max - this.min
                }
                else{
                    span = this.min - this.max                
                }
                const mad = this.majorDivision <= 0 ? 1 : this.majorDivision;
                const mid = this.minorDivision <= 0 ? 1 : this.minorDivision;
                const ma = span / mad
                const mi = span / ma / mid
                return {major:ma,minor:mi}
            }

        },
        mounted(){
           
            const dal = this.getElement('arc',true).getTotalLength()
            const sec = this.sectorData(dal)              
            const gauge = this.getElement('hn-gauge',true)
            gauge.style.setProperty('--dash',dal)
           
            sec.forEach(s =>{
                const sector = this.getElement(s.name,false)
                sector.style.setProperty("stroke-dasharray",s.css)
                sector.style.setProperty("stroke",s.color)
            })
            this.getElement('o-needle',true).style.rotate = this.rotation(null)
            this.$nextTick(() => {
                this.getElement('o-needle',true).style.transition= "rotate .5s"
            })
        }
    }
</script>
<style>
    .hn-sng {
        position: relative;
        user-select: none;
    }

    .hn-sng .label {
        position: absolute;
        font-size: 1rem;
        color: currentColor;
        text-align: center;
        width: 100%;
        overflow: hidden;
        white-space: nowrap;
        text-overflow: ellipsis;
    }

    .hn-sng .value {
        fill: currentColor;
        font-weigth: 600;
    }

    .hn-sng .unit {
        fill: currentColor;
        font-size: 0.4rem;
    }

    .hn-sng .measurement {
        fill: currentColor;
        font-size: 0.5rem;
    }

    .hn-sng .update {
        fill: currentColor;
        fill-opacity: 0.6;
        font-size: 0.35rem;
    }

    .hn-sng .num {
        transform-origin: center 64.15%;
        transform: translate(50%, 64.15%);
        fill: currentColor;
        fill-opacity: 0.6;
        font-size: .35rem;
    }

    .hn-sng .tick-minor {       
        stroke-dashoffset: 0;
        fill: none;
        stroke: currentColor;
        stroke-opacity: 0.6;
    }

    .hn-sng .tick-major {
        stroke-dashoffset: 0.75;
        fill: none;
        stroke: currentColor;
    }

    .hn-sng .sector {
        fill: none;
        stroke: transparent;
    }
    .hn-sng .sector:first-of-type {
        stroke-dashoffset:-0.5;
    }

    .hn-sng .o-needle {
        transform-origin: center 64.15%;
        transform: translate(50%, 64.15%);
        transition: unset;
    }

    .hn-sng .o-needle path,
    .hn-sng .o-needle circle {
        fill: currentColor;
    }
</style>
8 Likes

We are pleased your sleep was disturbed, may you have many more restless nights @hotNipi

4 Likes

Heh, the code doesn't know anything about it's birth moment in spacetime...

What theme settings do you use to get that dark theme?

I don't care too much about the theme as beauty but I do test for light and dark by just changing Pages and Groups background to some dark or some arbitrary light

OK, thanks. I was hoping you had found a combination that works well with the core nodes.

No if you mean that using vuetify things in ui_template may end with non-contrasting colors.
But for my creatures I do at least try to make it kind of self adoptive so the user can stay at the side of low code world. (No need directly to dig into complicated CSS world at least..)

Are the 'linear' and 'vertical' gauges still working?
The other gauges are working fine for me, but neither the 'linear' and 'vertical' gauge display any visual line.

test

Oh hang on... just looking at the first post in this topic... the CSS code has also changed, and updating my existing CSS template resolves the problem

1 Like

Even if I didn't put styles for new stuff into common CSS, it's still better practice to have one instance of your custom CSS in separate thing.
I do it that (wrong) way just to have it as single package to share.

If we added the style css to the template containing the gauge code, would that css then just influence that instance of the gauge, or would it influence all other gauges (or other widgets) in the same group.
(I'm aware that if it's separate that we can apply a class to isolate them)

Choose the scope for CSS as needed.

image

If it is "All pages" - you can put widgets on any page of your dashboard - they can reach defined rules.
With the Single page the CSS is targeted only for that page and widgets on other page can't reach it.
You can have different look and feel for different pages.

Having scoped CSS for widget itself - if you have many widgets with absolutely same CSS rules - it just gives bunch of work for browser to parse it and leave latest defined rule to affect. If CSS rules differ widget by widget - Maybe reasonable, but mostly indicates that it can be done much better.. It's just not realistic that all of the rules are completely different ...

2 Likes

Hi

Excellent job, the gauge is really nice and "versatile".

One thing i noticed, the code in "msg: function(){" where the payload is processed, the "if" that checks the return value from "this.validate(this.msg.payload)" should be comparing the return with "null" instead of "(!v)", otherwise if the payload is '0' the needle position and text is not updated.

Should be something like this.

msg: function(){
        if(this.msg.payload != undefined){                   
            const v = this.validate(this.msg.payload)                   
            if(v == null){
                return
            }
            this.value = v                    
            this.getElement('o-needle',true).style.rotate = this.rotation(this.value)                    
        }
    }
2 Likes

Oops I did it again .

Here's the fixed code with a bit of bonus :gift: :upside_down_face:

Code HERE
<template>
    <div class="hn-sng">
        <div class="label">{{label}}</div>
        <svg ref="hn-gauge" width="100%" height="100%" viewBox="0 0 100 100">
            <g>
                <path v-for="(item, index) in sectors" :key="index" :ref="'sector-' + index" class="sector" stroke-width="5" d="M 10 90 A 47.5 47.5 25 1 1 90 90"></path>                
            </g>
            <g>
                <path class="tick-minor" stroke-width="5" d="M 10 90 A 47.5 47.5 25 1 1 90 90"></path>
                <path ref="arc" class="tick-major" stroke-width="5" d="M 10 90 A 47.5 47.5 25 1 1 90 90"></path>
            </g>            
            <g>
                <text v-for="(item, index) in numbers" :key="index" class="num" text-anchor="middle" y="-37" :style="`rotate: ${item.r}deg;`">{{item.n}}</text>
            </g>
            <g>
                <text class="measurement" y="48" x="50%" text-anchor="middle">{{measurement}}</text>
                <text class="unit" y="75" x="50%" text-anchor="middle">{{unit}}</text>
                <text class="value" y="90" x="50%" text-anchor="middle">{{formattedValue}}</text>
                <text v-if="showUpdate" ref="update" class="update" y="100" x="0" text-anchor="left">{{formattedUpdate}}</text>
            </g>
            <g ref="o-needle" class="o-needle">
                <path d="M 0,0 -1.5,0 -0.15,-40 0.15,-40 1.5,0 z"></path>
                <circle cx="0" cy="0" r="3"></circle>
            </g>
        </svg>
    </div>
</template>
<script>
export default{
        data(){
            return {
                //define me here
                min:-50,
                max:60,
                unit:"kg",
                label:"Yet another gauge",
                measurement:"Potatoes",
                updateLabel:"Last update:",
                sectors:[{start:-28,end:-6,color:"#63cc25"},{start:5,end:20,color:"orange"},{start:20,end:60,color:"red"}],
                showUpdate:true,
                //no need to change
                value:0,
                updateTime:0                             
            }
        },
        methods:{
            getElement: function(name,base){
                if(base){
                    return this.$refs[name]
                }
                return this.$refs[name][0]
            },
            validate: function(data){
                let ret                
                if(typeof data !== "number"){
                    ret = parseFloat(data)
                    if(isNaN(ret)){
                        console.log("BAD DATA! gauge id:",this.id,"data:",data)
                        return null
                    }
                }                    
                else{
                    ret = data
                }                
                return ret
            },
            range:function (n, p, r) {           
                if (n < p.minIn) {
                    n=p.minIn;
                }
                if (n > p.maxIn) {
                    n = p.maxIn;
                }
                if(r){
                    return Math.round(((n - p.minIn) / (p.maxIn - p.minIn) * (p.maxOut - p.minOut)) + p.minOut);
                }
                return ((n - p.minIn) / (p.maxIn - p.minIn) * (p.maxOut - p.minOut)) + p.minOut;
            },
            generateNumbers:function(min,max){               
                const nums = [{r:238,n:0},{r:262,n:0},{r:286,n:0},{r:310,n:0},{r:335,n:0},{r:360,n:0},{r:385,n:0},{r:409,n:0},{r:434,n:0},{r:458,n:0},{r:123,n:0}];
                let t = (max - min) / 10;
                let i = min;
                for (let e = 0; e < 11; ++e){
                    nums[e].n = i
                    i = parseFloat((i + t).toFixed(2));                   
                }
                return nums 
            },
            sectorData:function(full){               
                let params, pos, span, sec, str
                let ret = [] 
                this.sectors.forEach((sector,idx) => {
                    sec = {name:'sector-'+idx,color:sector.color}
                    params = {minIn:this.min, maxIn:this.max, minOut:0, maxOut:full}                   
                    pos = this.range(sector.start,params,false)
                    params = {minIn:0, maxIn:this.max-this.min, minOut:0, maxOut:full}
                    span = this.range(sector.end - sector.start,params,false)                    
                    sec.css = "0 "+pos+" "+span+" var(--dash)"
                    ret.push(sec)
                })
                return ret
            },
            rotation:function(v){
                const params = {minIn:this.min, maxIn:this.max, minOut:-123, maxOut:123};
                return this.range(v,params,false) +'deg'          
            }

        },
        watch: {
            msg: function(){
                if(this.msg.payload != undefined){                   
                    const v = this.validate(this.msg.payload)                   
                    if(v == null){
                        return
                    }
                    this.value = v
                    this.updateTime = Date.now()                    
                    this.getElement('o-needle',true).style.rotate = this.rotation(this.value)                    
                }
            }
        },
        computed: {
            formattedValue: function () {
                return this.value.toFixed(1)
            },
            formattedUpdate:function (){
                return this.updateLabel+" "+this.time
            },
            time:function(){
                return new Intl.DateTimeFormat('default', {hour: 'numeric', minute: 'numeric',second: 'numeric'}).format(this.updateTime);
            },
            numbers:function(){
                return this.generateNumbers(this.min,this.max)
            }                
        },
        mounted(){
           
            const dal = this.getElement('arc',true).getTotalLength()
            const sec = this.sectorData(dal)              
            const gauge = this.getElement('hn-gauge',true)
            gauge.style.setProperty('--dash',dal)
           
            sec.forEach(s =>{
                const sector = this.getElement(s.name,false)
                sector.style.setProperty("stroke-dasharray",s.css)
                sector.style.setProperty("stroke",s.color)
            })            

        }

    }
</script>
<style>
    
.hn-sng{
position:relative;
}
.hn-sng .label{
position:absolute;
font-size:1rem;
color:currentColor;
text-align:center;
width:100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.hn-sng .value {
fill:currentColor;
font-weigth:600;
}
.hn-sng .unit {
fill:currentColor;
font-size:0.4rem;
}
.hn-sng .measurement {
fill:currentColor;
font-size:0.5rem;
}
.hn-sng .update {
fill:currentColor;
fill-opacity:0.6;
font-size:0.35rem;
}
.hn-sng .num{
transform-origin: center 64%;
transform: translate(50%, 64%);
fill:currentColor;
fill-opacity:0.6;
font-size:.35rem;
}
.hn-sng .tick-minor{
stroke-dasharray: 0.5 0 0 3.53;
stroke-dashoffset:-0.5;
fill:none;
stroke:currentColor;
stroke-opacity:0.6;
}
.hn-sng .tick-major{
stroke-dasharray: 2 0 0 18.1;
fill:none;
stroke:currentColor;
}
.hn-sng .sector{
fill:none;
stroke:transparent;
}
.hn-sng .o-needle{
transform-origin: center 64%;
transform: translate(50%, 64%);
transition:rotate .5s;
}
.hn-sng .o-needle path, .hn-sng .o-needle circle{
fill:currentColor;
}
</style>
1 Like

I have managed to learn enough about html svg and dynamic styling to make some adjustments to @hotNipi's gauge. If anyone is interested, I have:

  1. Added the ability to specify the major and minor division lengths in input units. In the example below major is 5 and minor 1.
  2. Allowed the pointer to travel a bit past the end points so it is more obvious if an out of range value is received.
  3. Increased the length of the pointer a bit.
  4. Ensured that the numbers round the scale are always integers, by adding toFixed(0). Without that, sometimes one could end up with numbers with lots of decimal digits which completely mess it up.
  5. Fixed the problem in Watch msg that has been pointed out. On a side note, instead of
                    if(v === null){
                        return
                    }

could one use just

v ?? return

I haven't tried it but I think it should work.

image

<!-- Gauge based on original work by @HotNipi -->
 <template>
    <div class="hn-sng">
        <div class="label">{{label}}</div>
        <svg ref="hn-gauge" width="100%" height="100%" viewBox="0 0 100 100">
            <g>
                <path v-for="(item, index) in sectors" :key="index" :ref="'sector-' + index" class="sector" stroke-width="5" d="M 10 90 A 47.5 47.5 25 1 1 90 90"></path>                
            </g>
            <g>
                <path class="tick-minor" stroke-width="5" d="M 10 90 A 47.5 47.5 0 1 1 90 90" :style="tickStyle(this.minorDivision, 0.5)"></path>
                <path ref="arc" class="tick-major" stroke-width="5" d="M 10 90 A 47.5 47.5 25 1 1 90 90" :style="tickStyle(this.majorDivision, 2)"></path>
            </g>            
            <g>
                <text v-for="(item, index) in numbers" :key="index" class="num" text-anchor="middle" y="-37" :style="`rotate: ${item.r}deg;`">{{item.n}}</text>
            </g>
            <g>
                <text class="measurement" y="48" x="50%" text-anchor="middle">{{measurement}}</text>
                <text class="unit" y="75" x="50%" text-anchor="middle">{{unit}}</text>
                <text class="value" y="90" x="50%" text-anchor="middle">{{formattedValue}}</text>
            </g>
            <g ref="o-needle" class="o-needle">
                <path d="M 0,0 -1.5,0 -0.15,-43 0.15,-43 1.5,0 z"></path>
                <circle cx="0" cy="0" r="3"></circle>
            </g>
        </svg>
    </div>
</template>
<script>
export default{
        data(){
            return {
                //define me here
                min:40,
                max:60,
                majorDivision: 5,       // number of input units for each (numbered) major division
                minorDivision: 1,       // number of input units for each minor division
                unit:"°C",
                label:"Hot Water",
                measurement:"Temperature",
                sectors:[{start:20,end:46,color:"blue"},{start:46,end:52,color:"green"},{start:52,end:60,color:"red"}],
                
                //no need to change
                value:0                             
            }
        },
        methods:{
            getElement: function(name,base){
                if(base){
                    return this.$refs[name]
                }
                return this.$refs[name][0]
            },
            validate: function(data){
                let ret                
                if(typeof data !== "number"){
                    ret = parseFloat(data)
                    if(isNaN(ret)){
                        console.log("BAD DATA! gauge id:",this.id,"data:",data)
                        return null
                    }
                }                    
                else{
                    ret = data
                }                
                return ret
            },
            range:function (n, p, r) {           
                if (n < p.minIn) {
                    n=p.minIn;
                }
                if (n > p.maxIn) {
                    n = p.maxIn;
                }
                if(r){
                    return Math.round(((n - p.minIn) / (p.maxIn - p.minIn) * (p.maxOut - p.minOut)) + p.minOut);
                }
                return ((n - p.minIn) / (p.maxIn - p.minIn) * (p.maxOut - p.minOut)) + p.minOut;
            },
            generateNumbers:function(min,max,majorDivision){               
                const minDegrees = 237.36
                const maxDegrees = 482.64
                const degRange = maxDegrees-minDegrees
                const degPerDiv = degRange * majorDivision/(max-min)
                let nums = []
                for (let degrees=minDegrees; degrees<=maxDegrees; degrees+=degPerDiv) {
                    const n = ((degrees-minDegrees)/degRange * (max-min) + min).toFixed(0)
                    nums.push({r: degrees, n: n})
                }
                return nums 
            },
            sectorData:function(full){               
                let params, pos, span, sec, str
                let ret = [] 
                this.sectors.forEach((sector,idx) => {
                    sec = {name:'sector-'+idx,color:sector.color}
                    params = {minIn:this.min, maxIn:this.max, minOut:0, maxOut:full}                   
                    pos = this.range(sector.start,params,false)
                    params = {minIn:0, maxIn:this.max-this.min, minOut:0, maxOut:full}
                    span = this.range(sector.end - sector.start,params,false)                    
                    sec.css = "0 "+pos+" "+span+" var(--dash)"
                    ret.push(sec)
                })
                return ret
            },
            rotation:function(v){
                // allow pointer to go 10% off ends of scale
                const overflow = (this.max-this.min)*0.1
                const maxAngle = 122.64 * 1.2       // 1.2 as this is only half the range, so need 20%
                const min = this.min - overflow
                const max = this.max + overflow
                const params = {minIn:min, maxIn:max, minOut:-maxAngle, maxOut:maxAngle};
                return this.range(v,params,false) +'deg'          
            },
            tickStyle: function(division, width) {
                // division is the number of input units per tick
                // width is the width (length?) of the tick in svg units

                // total arc length in svg units
                const arcLength = 203.356
                // length in user units
                const range = this.max - this.min
                const tickPeriod = division/range * arcLength
                // marker is width wide, so gap is tickPeriod-width
                // stroke-dashoffset sets the first tick to half width
                return `stroke-dasharray: ${width} ${tickPeriod-width}; stroke-dashoffset: ${width/2};`
            },
        },
        watch: {
            msg: function(){
                if(this.msg.payload != undefined){                   
                    const v = this.validate(this.msg.payload)                   
                    if(v === null){
                        return
                    }
                    this.value = v
                    this.getElement('o-needle',true).style.rotate = this.rotation(this.value)                    
                }
            }
        },
        computed: {
            formattedValue: function () {
                return this.value.toFixed(1)
            },
            numbers:function(){
                return this.generateNumbers(this.min,this.max,this.majorDivision)
            },
        },
        mounted(){
           
            const dal = this.getElement('arc',true).getTotalLength()
            const sec = this.sectorData(dal)              
            const gauge = this.getElement('hn-gauge',true)
            gauge.style.setProperty('--dash',dal)
            sec.forEach(s =>{
                const sector = this.getElement(s.name,false)
                sector.style.setProperty("stroke-dasharray",s.css)
                sector.style.setProperty("stroke",s.color)
            })

        }

    }
</script>
<style>
    .hn-sng{
        position:relative;
    }
    .hn-sng .label{
        position:absolute;
        font-size:1rem;
        color:currentColor;
        text-align:center;
        width:100%;
        overflow: hidden;
        white-space: nowrap;
        text-overflow: ellipsis;
    }
    .hn-sng .value {
        fill:currentColor;
    }
    .hn-sng .unit {
        fill:currentColor;
        font-size:0.4rem;
    }
    .hn-sng .measurement {
        fill:currentColor;
        font-size:0.5rem;
    }
    .hn-sng .num{
        transform-origin: center 64%;
        transform: translate(50%, 64%);
        fill:currentColor;
        fill-opacity:0.6;
        font-size:.35rem;
    }
    .hn-sng .tick-minor{
        fill:none;
        stroke:currentColor;
        stroke-opacity:0.6;
    }
    .hn-sng .tick-major{
        fill:none;
        stroke:currentColor;
    }
    .hn-sng .sector{        
        fill:none;
        stroke:transparent;      
    }    
    .hn-sng .o-needle{        
        transform-origin: center 64%;
        transform: translate(50%, 64%);
        transition:.5s;
    }    
    .hn-sng .o-needle path, .hn-sng .o-needle circle{
        fill:red;
    }
    
</style>
3 Likes