Wont be long (they are in labs): Sparklines — Vuetify
The space between them is the gap what is defined by dashboard.
Widget takes 100% available space.
If you don't want to mess with provided styles for all gauges in all situations but just in this card or dedicated widgets only - add a class to them to override appearance of widget
.my-level .led-level{
padding-inline:5px;
}
where my-level
is that classname you wrote where you wont it to be applied.
Otherwise to add left and right padding for all of them
.led-level{
padding-inline:5px;
}
Whole widget can be made responsive by all means but only for dedicated use cases. It takes to break the layout at those exact sizes you as the creator of the page will define.
The responsiveness of DB2 is not magical resolver of every possible use cases. It still requires to make some compromises like placing and sizing your widgets so that they look OK on all your screens. That means you can't have that many widgets in one row if they don't fit with the smallest screen you are using.
But saying so - you have the source code of the level - you can make it to be multiple of them in one container which you can control and then make your own placement/sizing rules inside that container so it fits always.
8 posts were split to a new topic: Dash 2.0 Sparkline
That's really cool.
Does anyone has an idea how to implement this in Grafana, too ?
Hi @Giamma,
I had the same doubt as you last week, because I hadn't seen it before in my life. It is used for optional chaining.
Bart
Ok, thank you ....
Now that newer versions of node.js, it is safe to use that in runtime code.
However, you probably shouldn't use it for front-end browser code since it only started being supported early 2020. iOS support came with v13.4 in March 2020. So if there is a chance that your users might have older devices, best avoided.
I target my front-end code for early 2019 as a rule as this seems to cover all but the oldest devices. One way around this issue is to use a build-tool such as ESBUILD to produce production, minified versions of front-end code. It lets you write current generation code but can build to any previous JavaScript or browser version.
Thank you ...
I was kindly asked for a bit redesign for horizontal level (add icon, spread dots evenly, make it more responsive, get rid of filter usage ...) A bit dedicated design or so. But I'll share anyway
It uses container query's for responsiveness. Those are set to solid values so the break points may need adjustments if you plan to use it on your dashboard. (break points can't be dynamically adjustable)
Code
<template>
<div class="led-level">
<div :class="icon ? 'led-level-grid-2':'led-level-grid-1'">
<div v-if="icon" class="led-level-icon">
<v-icon aria-hidden="false">{{icon}}</v-icon>
</div>
<div class="led-level-content">
<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">
<div v-for="(color, index) in colors" :key="index" class="led-level-led" :ref="'dot-' + index"></div>
</div>
<div class="led-level-limits">
<span class="led-level-limit">{{min}}</span>
<span v-for="(tick, index) in ticks" :key="index" class="led-level-limit" :ref="'tick-' + index">{{tick}}</span>
<span class="led-level-limit">{{max}}</span>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data(){
return {
//Define me here
label:"Level long name", // The label
icon:"mdi-account", //mdi-account (optional) the icon
min:0, // Smallest expected value
max:100, // Highest expected value
unit:"cm³",// The unit of the measurement
dim:0.2, //How dim is led when not glowing
animate:true, // Animating led's is not most performant thing in the world.
colors:[
"#00e300",
"#00e300",
"#00e300",
"#00e300",
"#00e300",
"#00e300",
"#00e300",
"#00e300",
"#00e300",
"#00e300",
"#00e300",
"#00e300",
"#00e300",
"#00e300",
"#00e300",
"#ffa916",
"#ffa916",
"#ffa916",
"#ff4c16",
"#ff4c16"],
ticks:[50,75], //optional tick values
//no need to change those
value:0,
previousValue:0,
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! 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;
},
tickPosition: function(tv){
return Math.floor(((tv - this.min) / (this.max - this.min)) * 100)+'%';
},
lit: function(){
if(this.inited == false){
return
}
const down = this.previousValue > this.value
let time = down ? 1 : 0.2
let step = down ? 0.12 : 0.06
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.opacity = 1;
time += step
}
else if(i==this.full()){
dot.style.opacity = this.half()
}
else{
dot.style.opacity = this.dim
}
if(down){
time -= step
}
else{
time -= step
}
dot.style.transition = this.animate && time > 0 ? "opacity "+time+"s" : "unset";
})
this.previousValue = this.value
}
},
watch: {
msg: function(){
if(this.msg.payload !== undefined){
const v = this.validate(this.msg.payload)
if(v === null){
return
}
this.value = v
this.lit()
}
}
},
computed: {
formattedValue: function () {
return this.value.toFixed(2)
},
percentage: function(){
return Math.floor(((this.value - this.min) / (this.max - this.min)) * 100);
}
},
mounted(){
this.colors.forEach((c,i) => {
let dot = this.getElement("dot-"+i);
if(!dot){
return
}
dot.style.backgroundColor = c
}
);
this.ticks.forEach((t,i) => {
let tick = this.getElement("tick-"+i);
if(!tick){
return
}
tick.style.left = this.tickPosition(t)
}
);
this.inited = true;
},
unmounted () {
}
}
</script>
CSS
.led-level{
container-type: inline-size;
container-name: led-level;
--break-point:160;
}
.led-level-grid-2 {
display:grid;
grid-template-columns: 2rem auto;
gap:1rem;
height: 100%;
}
.led-level-grid-1 {
display:grid;
grid-template-columns: 1fr;
gap:1rem;
height: 100%;
}
@container led-level (max-width: 160px) {
.led-level-grid-2{
grid-template-columns: 1rem auto;
}
.led-level-icon .v-icon{
font-size: calc(var(--v-icon-size-multiplier)* 2em) !important;
}
}
.led-level-icon{
display:flex;
justify-content: center;
align-items: center;
}
.led-level-icon .v-icon{
font-size: calc(var(--v-icon-size-multiplier)* 3em);
}
.led-level-content {
container-type: inline-size;
container-name: level-content;
display: grid;
grid-template-rows: 1.3em minmax(3px, 1fr) .7em;
gap: 2px;
height: 100%;
}
.led-level-text {
font-size: 1.25em;
line-height: 1em;
align-self: end;
display: flex;
justify-content: space-between;
user-select: none;
overflow: auto;
}
.led-level-label{
font-size: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.led-level-value {
font-weight: bold;
}
.led-level-unit {
font-size: .75em;
font-weight: normal;
padding-inline-start: 0.15em;
}
.led-level-stripe {
display: flex;
}
.led-level-led {
background: #ffffff;
width: 100%;
height: 100%;
border-left: 1px solid rgb(var(--v-theme-group-background));
border-right: 1px solid rgb(var(--v-theme-group-background));
border-radius: 0px;
}
@container level-content (max-width: 150px) {
.led-level-led:nth-child(even){
display:none;
}
.led-level-limit:not(:first-child):not(:last-child){
display:none;
}
}
@container level-content (max-width: 130px) {
.led-level-label{
display:none;
}
.led-level-text{
justify-content:flex-end;
}
}
.led-level-limits {
display: block;
position: relative;
font-size: .75em;
line-height: 1;
user-select: none;
}
.led-level-limit{
position:absolute;
transform:translate(-50%,0);
}
.led-level-limit:first-child{
left:0;
transform: translate(0, 0);
}
.led-level-limit:last-child{
right:0;
transform: translate(0, 0);
}
What about this one? Responsive and stuff...
Yes, you'll need to have dark background for it. Long way to be a widget ...
CODE
<template>
<div class="fg-body">
<div class="fg-display">
<div class="fg-backplate">
<div ref="display" class="fg-numbers" >
<div v-for="(n, index) in numbers" :key="index" class="fg-num">{{n}}
</div>
</div>
<div class="fg-needle"></div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
min:0,
max:100,
value:0
}
},
computed: {
numbers:function(){
var t = (this.max - this.min) / 10;
let i = this.min;
let r = []
for (let e = 0; e < 11; ++e){
r.push(i),
i = parseFloat((i + t).toFixed(2));
}
return r
}
},
methods: {
range :function (n, p, a, r) {
if (a == "clamp") {
if (n < p.minin) {
n=p.minin;
}
if (n> p.maxin) {
n = p.maxin;
}
}
if (a == "roll") {
let d = p.maxin - p.minin;
n = ((n - p.minin) % d + d) % d + p.minin;
}
let v = ((n - p.minin) / (p.maxin - p.minin) * (p.maxout - p.minout)) + p.minout;
if (r) {
v = Math.round(v);
}
return v
},
percentage: function(){
let p = 100 - ((this.value - this.min) / (this.max - this.min)) * 100
let params = {minin:0, maxin:100, minout:2.5, maxout:97.5};
return this.range(p,params)
},
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
},
move(){
const display = this.$refs.display;
if(!display){
return
}
display.style.left = this.percentage()+"%"
}
},
watch: {
msg: function(){
if(this.msg.payload !== undefined){
const v = this.validate(this.msg.payload)
if(v === null){
return
}
this.value = v
this.move()
}
}
},
mounted() {
},
unmounted() {
}
}
</script>
<style>
.fg-body {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-content: center;
flex-wrap: wrap;
}
.fg-numbers {
position:relative;
display: flex;
flex-direction: row;
justify-content: space-between;
height: 100%;
font-size: 1.25rem;
word-wrap: normal;
align-content: center;
flex-wrap: wrap;
transform: translateX(-50%);
transition: left 1s ease-in-out;
}
.fg-backplate {
width: 600px;
flex-shrink: 0;
position: relative;
}
.fg-numbers:before {
content: "";
position: absolute;
right: 0%;
width: 100%;
top: 0px;
height: 25%;
background: linear-gradient(to right,
#00000050,
#00000050 50%,
transparent 50%,
transparent);
background-size: 0.6% 100%;
}
.fg-numbers:after {
content: "Gauges by hotNipi ®";
position: absolute;
font-size: 9px;
left: -90px;
top: 9px;
color: #00000080;
}
.fg-display {
width: 100%;
height: 100%;
display:flex;
justify-content: center;
position: relative;
box-sizing: border-box;
overflow: hidden;
border: 3px solid black;
background-color: #cfcfcf;
outline: 1px solid #4d4d4d;
outline-offset: -2px;
border-radius: 6px;
box-shadow: inset 0 0 9px 1px black;
}
.fg-display:before,
.fg-display:after {
content: "";
position: absolute;
inset: 0;
}
.fg-display:before {
background: linear-gradient(270deg, #000000b0, transparent 20%);
}
.fg-display:after {
background: linear-gradient(90deg, #000000b0, transparent 20%);
}
.fg-num {
position: relative;
text-align: center;
top: 3px;
width: 30px;
color: black;
}
.fg-needle {
position: absolute;
left: calc(50% - 1px);
top: 1px;
width: 2px;
height: 21px;
background-color: #eb1e1e;
box-shadow: 0px 0px 7px 2px #0000008c;
}
</style>
As always. Super nice.
A call to try it and maybe you have ideas to make it better smarter fancier ...
It is going to be a widget. edgewise-meter
Just copy the code into the ui_template and go.
CODE
<template>
<div class="fg-wrapper" :style="styles">
<div v-if="label" class="fg-label">{{label}}</div>
<div class="fg-body" :class="{'fg-dark': dark, 'fg-light': !dark}">
<div ref="display" class="fg-display">
<div class="fg-backplate">
<div ref="plate" class="fg-numbers" >
<div v-for="(n, index) in numbers" :key="index" class="fg-num">{{n}}</div>
</div>
<div v-if="sizeError" class="fg-size-error fg-blonk">
<p>The configured size is too small. The digits cannot be placed in exact locations.</p>
</div>
</div>
<div ref="needle" class="fg-needle"></div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
min:0,
max:10,
count:20,// (even int) not all min - max - count combinations can be nicely divided to make a scale. real widget will help with that.
size:800,// adjust space between digits. too small will show runtime error
dark:false, // turn on if the group background is dark
sectors:[{start:0,color:"lightblue"},{start:3,color:"transparent"},{start:7,color:"#ffc55c"},{start:9,color:"#fc5b5b"}],
label:"T-out °C",
smallDigits:true,
logo:"",
//no need to change
value:0,
sizeError:false,
}
},
computed: {
numbers:function(){
var t = (this.max - this.min) / this.count;
let i = this.min;
let r = []
for (let e = 0; e < this.count+1; ++e){
r.push(i),
i = parseFloat((i + t).toFixed(2));
}
return r
},
styles:function() {
return {
"--logo": this.logo ? JSON.stringify(this.logo) : undefined,
"--size": this.size,
"--small-digits":this.smallDigits ? "small" : "inherit",
"--gradient":this.sectorGradient(),
"grid-template-columns":this.label ? "auto 1fr" : "1fr",
}
}
},
methods: {
sectorGradient: function(){
let gradient = "repeating-linear-gradient(to right, #00000040, #00000040 40%, transparent 40%, transparent)"
if(this.sectors.length == 0){
return gradient
}
gradient = gradient.concat(', linear-gradient(to right,')
let pos
let total = this.sectors.length -1
this.sectors.forEach((s,i) => {
if(i == 0){
if(s.start == this.min){
gradient = gradient.concat(s.color,", ")
if(i + 1 <= total){
pos = this.position(this.sectors[i+1].start).toString()
gradient = gradient.concat(s.color," ",pos,"%, ")
}
}
else{
gradient = gradient.concat('transparent, transparent ')
pos = this.position(s.start).toString()
gradient = gradient.concat(pos,"%, ")
if(i + 1 <= total){
pos = this.position(s.start).toString()
gradient = gradient.concat(s.color," ",pos,"%, ")
pos = this.position(this.sectors[i+1].start).toString()
gradient = gradient.concat(s.color," ",pos,"%, ")
}
}
}
else if(i == total){
pos = this.position(s.start).toString()
gradient = gradient.concat(s.color," ",pos,"%, ")
gradient = gradient.concat(s.color)
}
else{
pos = this.position(s.start).toString()
gradient = gradient.concat(s.color," ",pos,"%, ")
if(i + 1 <= total){
pos = this.position(this.sectors[i+1].start).toString()
gradient = gradient.concat(s.color," ",pos,"%, ")
}
}
})
gradient = gradient.concat(")")
return gradient
},
range :function (n, p, a, r) {
if (a == "clamp") {
if (n < p.minin) {
n=p.minin;
}
if (n> p.maxin) {
n = p.maxin;
}
}
if (a == "roll") {
let d = p.maxin - p.minin;
n = ((n - p.minin) % d + d) % d + p.minin;
}
let v = ((n - p.minin) / (p.maxin - p.minin) * (p.maxout - p.minout)) + p.minout;
if (r) {
v = Math.round(v);
}
return v
},
position: function(target){
const v = target ?? this.value
const l = this.size/(this.count+1)/2
const d = l * 100 / this.size
let p = ((v - this.min) / (this.max - this.min)) * 100
p = target ? p : 100 - p
const params = {minin:0, maxin:100, minout:d, maxout:100-d};
return this.range(p,params)
},
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
},
move(){
const plate = this.$refs.plate;
const display = this.$refs.display;
if(!display || !plate){
return
}
plate.style.left = this.position()+"%"
}
},
watch: {
msg: function(){
if(this.msg.payload !== undefined){
const v = this.validate(this.msg.payload)
if(v === null){
return
}
this.value = v
this.move()
}
}
},
mounted() {
setTimeout(()=>{
const plate = this.$refs.plate;
const needle = this.$refs.needle
if(!plate || !needle){
return
}
let w = 0
Array.from(plate.children).forEach(n => {
w += parseFloat(getComputedStyle(n).getPropertyValue('width'))
})
if(w - this.size > 0.2){
this.sizeError = true
plate.classList.add('fg-blink')
needle.classList.add('fg-blink')
}
},100)
},
unmounted() {
}
}
</script>
<style>
.fg-light{
--mix:20%;
--fg-shadow:rgba(var(--v-theme-on-group-background),var(--mix));
--fg-background:rgb(var(--v-theme-group-background));
--fg-outline:rgba(var(--v-theme-on-group-background),40%);
}
.fg-dark{
--mix: 75%;
--fg-shadow: rgba(var(--v-theme-group-background), var(--mix));
--fg-background: rgb(var(--v-theme-on-group-background));
--fg-outline: rgba(var(--v-theme-group-background), 100%);
}
.fg-wrapper{
display: grid;
gap: 1rem;
align-items: center;
height: 100%;
}
.fg-label {
min-width: 1rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.fg-body {
position: relative;
min-width: 3rem;
width: 100%;
height: 100%;
display: flex;
align-content: center;
}
.fg-body.fg-light {
filter: drop-shadow(0px 2px 4px var(--fg-shadow));
}
.fg-display {
width: 100%;
height: 100%;
display:flex;
justify-content: center;
position: relative;
box-sizing: border-box;
overflow: hidden;
border: 3px solid var(--fg-shadow);
background-color: var(--fg-background);
outline: 1px solid var(--fg-outline);
outline-offset: -2px;
border-radius: 6px;
box-shadow: inset 0 0 9px 1px var(--fg-shadow);
transition: background-color 1s;
}
.fg-display::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, var(--fg-shadow), transparent 30%, transparent 70%, var(--fg-shadow));
}
.fg-backplate {
width: calc(var(--size) * 1px);
flex-shrink: 0;
position: relative;
}
.fg-numbers {
position:relative;
display: flex;
flex-direction: row;
justify-content: space-between;
height: 100%;
font-size: 1.25rem;
word-wrap: normal;
align-content: center;
transform: translateX(-50%);
transition: left 1s ease-in-out;
}
.fg-numbers :nth-child(even){
font-size: var(--small-digits);
}
.fg-numbers:after {
content: "";
position: absolute;
right: 0%;
width: 100%;
top: 0px;
height: 25%;
background: var(--gradient,repeating-linear-gradient(to right, #00000040, #00000040 40%, transparent 40%, transparent));
background-size: 4px, 100%;
}
.fg-numbers:before {
content: var(--logo,"Made in Estonia hotNipi ® Gauges");
position: absolute;
font-size: xx-small;
text-align: end;
inset: 0;
width: 20ch;
translate: -120%;
color: var(--fg-outline);
}
.fg-num {
position: relative;
text-align: center;
width: 100%;
color: black;
align-self: flex-end;
border-bottom: 3px solid transparent;
}
.fg-needle {
position: absolute;
left: calc(50% - 0.5px);
top: 0;
width: 1px;
height: 100%;
background-color: #eb1e1e;
box-shadow: 0px 0px 7px 2px var(--fg-shadow);
}
.fg-dark .fg-needle {
box-shadow: 0px 0px 12px 1px var(--fg-shadow);
}
.fg-size-error {
position: absolute;
inset: 0;
font-size: x-small;
color:red;
text-align: center;
display: flex;
justify-content:center;
}
.fg-size-error > p{
max-width: 140px;
}
@keyframes blink {
50% {
opacity: 0.0;
}
}
.fg-blink{
animation: blink 4s step-start 0s infinite;
}
.fg-blonk{
animation: blink 4s step-start 2s infinite;
}
</style>
For fun, I just asked ChatGPT 4o the following question:
Create a web component that outputs a visible gauge that looks like the attached image. The output must scale and be responsive to page sizes and layouts and user font size preferences. The gauge's scale changes left and right, the needle stays static in the centre. The gauge mimics a physical edgewise meter. There should be attributes to set the maximum and minimum ranges, an optional label and the HTML output should be reasonably accessible (at least WCAG 2.2 A).
It has given me an answer - no idea whether it will work and it won't let me share it I'm afraid. I'll give it a go and see if it makes any sense at all.
It actually is simple thing to create with plain html/css but what makes it complex is the configuration options and forced container system where it will be used. So no doubt the AI can make some plain and straight thing. I'll stay away. Making things from scratch is fun.
It's a lovely gauge, I can almost see the moving coil hauling the scale backwards and forwards!
"Made in Estonia" is a nice touch too.
I see that your .fg-numbers div has an ease-in-out transition, but it looks to me rather abrupt.
What do you think of a bezier transition to simulate moving a significant weight, with a little overrun?
Default
transition: left 1s cubic-bezier( 0.6, 0.06, 0.132, 1.15 );
I would like the numbers to fade a little out of focus at the sides. I tried to come up with a frosted glass overlay to do this but can't work out how to fade the effect in and out.
I like the creativity but I'm lazy enough to be happy if something does some heavy lifting for me so I can concentrate on the design rather than the raw engineering. The architects mindset perhaps.
I very much doubt that ChatGPT will come at all close to something finely engineered like your gauges though @hotNipi
It is , but I doubt the one deliberately don't use the "logo" option ..
It is matter of taste kind of. I don't think it should be something the user should be able to easily configure but as CSS overrides are fee and easy to use , I think that is enough of freedom for that case.
I tried. And it is possible and stuff. But I have a friend with pretty good understanding of digital art and all that flew out the window.
Key points :
- Always readable
- Muddy look is no no
- Easy to render
- If it doesn't make it better it makes it worse
- Less is more
There was more
Just needs a bit of screeching sound to be perfect ..
Well, you will doubtless be glad to know that your creativity and engineering skills are still safe for now - I'm not sure just yet what it produced but it certainly does not work!