I've been terribly remis in not getting hotNipi's great gauge component out there after his generosity in sharing it. Too much life getting in the way.
It is a HTML web component - I've made a few tweaks to it but it probably still needs a few more and some documentation - still it works (or it did when hotNipi sent it over , I think I messed up the rotation calculation somewhere so that can look a bit odd - let me know if you find my error ) so presented here as is. Ultimately, I'll turn it into an npm published version so it can be used anywhere you want to use it but with some extra magic built in to make it easy to use with uibuilder.
hotnipi-gauge.js
/**
* TODO:
* - Max/Min not working correctly
* - Add last update timestamp to LED title
*/
const componentName = 'gauge-hotnipi'
const className = 'GaugeHotnipi' // eslint-disable-line no-unused-vars
// just for syntax highlighting in VSCode (requires the lit-html extension)
function html(strings, ...keys) {
return strings.map( (s, i) => {
return s + (keys[i] || '')
}).join('')
}
const template = document.createElement('template')
template.innerHTML = html`
<style>
:host{
--hng-needle-color: var(--needle-color,#e02f2b);
--hng-zone-color-high: var(--zone-color-high,#ff5d4e75);
--hng-zone-color-warn: var(--zone-color-warn,#ffb52d75);
--hng-zone-color-normal: var(--zone-color-normal,#91ff4e55);
--hng-zone-color-low: var(--zone-color-low,#4ec3ff85);
--hng-needle-speed: var(--needle-speed,0.5);
}
.g-wrapper {
display: grid;
grid-template-rows: 1fr;
width: 100%;
height: 100%;
align-content: center;
align-items: center;
justify-items: center;
}
.g-wrapper-label-0 .g-container {
height: calc(100% - 6px);
}
.g-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
user-select: none;
width: 100%;
height: 100%;
}
.g-body {
position: relative;
display: flex;
align-content: center;
align-items: center;
justify-content: center;
height: 98%;
width: 98%;
border-radius: 15%;
box-shadow: 0px 5px 8px #00000030;
background: linear-gradient(0deg, rgb(193, 193, 193) 0%, rgba(215, 215, 215, 1) 99%, rgba(236, 236, 236, 1) 100%);
}
.g-round{
border-radius: 100%;
}
.g-body::before {
content: '';
position: absolute;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
opacity: 0.1;
border-radius: 15%;
}
.g-ring {
position: relative;
display: flex;
justify-content: center;
align-items: center;
align-content: center;
width: 94%;
height: 94%;
border-radius: 50%;
background: linear-gradient(180deg, rgb(172, 172, 172) 0%, rgba(215, 215, 215, 1) 99%, rgba(236, 236, 236, 1) 100%);
}
.quarter-top-right>.g-ring {
width: 90%;
height: 90%;
border-radius: 15% 85% 15% 15%;
}
.quarter-top-left>.g-ring {
width: 90%;
height: 90%;
border-radius: 85% 15% 15% 15%;
}
.g-plate {
position: relative;
overflow: hidden;
width: 93%;
height: 93%;
border-radius: 50%;
box-shadow: inset 0 0 15px #00000050;
/* background: radial-gradient(circle, rgb(196 205 209) 0%, rgb(177 183 186) 40%, rgb(191 193 194) 100%); */
}
.g-led{
position: absolute;
left:48%;
top:27%;
border-radius: 2em;
width: 0.5em;
height: 0.5em;
background-color: hsla(360 100% 50% / 100%);
box-shadow: 0 0 4px 1px hwb(360 0% 49%), inset 0px -5px 6px -3px hsl(360 100% 30%);
filter:saturate(0.05) brightness(3);
}
.g-led.active{
animation:blink 0.25s linear ;
animation-iteration-count: infinite;
}
@keyframes blink{
50%{filter: none;}
}
.g-ticks {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
filter: drop-shadow(2px 4px 6px black);
}
.g-tick {
position: relative;
left: 0;
top: 50%;
width: 100%;
height: 1px;
margin-bottom: -1px;
background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0) 2%, rgb(0 0 0 / 60%) 2%, rgb(0 0 0 / 60%) 10%, rgba(0, 0, 0, 0) 10%);
transform: rotate(calc(calc(270deg / var(--ga-tick-count)) * var(--ga-tick) - calc(calc(270deg / var(--ga-tick-count)) + 45deg)));
}
.g-tick.clock {
transform: rotate(calc(calc(360deg / var(--ga-tick-count)) * var(--ga-tick) - calc(calc(360deg / var(--ga-tick-count)) +270deg)));
}
.g-subtick {
position: relative;
left: 0;
top: 50%;
width: 100%;
height: 1px;
margin-bottom: -1px;
background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0) 2%, rgb(0 0 0 / 40%) 2%, rgb(0 0 0 / 40%) 6%, rgba(0, 0, 0, 0) 6%);
transform: rotate(calc(calc(270deg / var(--ga-subtick-count)) * var(--ga-tick) - calc(calc(270deg / var(--ga-subtick-count)) + 45deg)));
}
.g-subtick.clock {
transform: rotate(calc(calc(360deg / var(--ga-subtick-count)) * var(--ga-tick) - calc(calc(360deg / var(--ga-subtick-count)) + 270deg)));
}
.g-num {
position: absolute;
top: 50%;
left: 50%;
text-align: center;
transform: translate(-50%, -50%) rotate(calc(calc(270deg / var(--ga-tick-count)) * var(--ga-tick) - calc(calc(270deg / var(--ga-tick-count)) + 45deg))) translate(calc(-1px * var(--container-size) * var(--gn-distance))) rotate(calc(calc(270deg / var(--ga-tick-count)) * var(--ga-tick) *-1 - calc(calc(270deg / var(--ga-tick-count))*-1 - 45deg)));
}
.g-num.clock {
transform: translate(-50%, -50%) rotate(calc(calc(360deg / var(--ga-tick-count)) * var(--ga-tick) - calc(calc(360deg / var(--ga-tick-count)) + 270deg))) translate(calc(-1px * var(--container-size) * var(--gn-distance))) rotate(calc(calc(360deg / var(--ga-tick-count)) * var(--ga-tick) *-1 - calc(calc(360deg / var(--ga-tick-count))*-1 - 270deg)));
}
.g-nums {
position: absolute;
top: 0;
width: 100%;
height: 100%;
color: #000000a1;
font-size: calc(var(--digit-size) * 1%);
font-weight: 500;
filter: drop-shadow(2px 4px 10px black);
}
.g-needle {
position: absolute;
left: 0;
top: 49%;
width: 100%;
height: 2%;
filter: drop-shadow(0px 1px 3px #00000080);
background: linear-gradient(90deg, rgba(2, 0, 36, 0) 0, rgba(0, 0, 0, 0) 10%, var(--hng-needle-color) 10%, var(--hng-needle-color) 65%, rgba(0, 0, 0, 0) 65%);
transform: rotate(calc(270deg * calc(var(--gauge-value, 0deg) / 100) - 45deg));
transition: transform calc(1s * var(--hng-needle-speed));
}
.g-needle-secondary {
position: absolute;
left: 0;
top: 49%;
width: 100%;
height: 2%;
filter: drop-shadow(0px 1px 3px #00000080);
background: linear-gradient(90deg, rgba(2, 0, 36, 0) 0, rgba(0, 0, 0, 0) 15%, var(--hng-needle-color-secondary) 15%, var(--hng-needle-color-secondary) 50%, rgba(0, 0, 0, 0) 50%);
transform: rotate(calc(270deg * calc(var(--gauge-value-secondary, 0deg) / 100) - 45deg));
transition: transform calc(1s * var(--hng-needle-speed));
}
.g-needle.hour {
background: linear-gradient(90deg, rgba(2, 0, 36, 0) 0, rgba(0, 0, 0, 0) 20%, var(--hng-needle-color) 20%, var(--hng-needle-color) 50%, rgba(0, 0, 0, 0) 50%);
transition: unset;
transform: rotate(var(--time-hour));
}
.g-needle.minute {
top: 49.25%;
height: 1.5%;
background: linear-gradient(90deg, rgba(2, 0, 36, 0) 0, rgba(0, 0, 0, 0) 15%, var(--hng-needle-color) 15%, var(--hng-needle-color) 50%, rgba(0, 0, 0, 0) 50%);
transition: unset;
transform: rotate(var(--time-minute));
}
.g-needle.second {
top: 49.5%;
height: 0.5%;
background: linear-gradient(90deg, rgba(2, 0, 36, 0) 0, rgba(0, 0, 0, 0) 10%, var(--hng-needle-color) 10%, var(--hng-needle-color) 50%, rgba(0, 0, 0, 0) 50%);
transform: rotate(var(--time-second));
transition: unset;
}
.g-needle-ring {
position: absolute;
width: calc(var(--container-size) * 2.5%);
height: calc(var(--container-size) * 2.5%);
top: 50%;
left: 50%;
border-radius: 50%;
box-shadow: 0 1px 4px #0000009c;
background: linear-gradient(#e0e0e0, #b4b4b4);
transform: translate(-50%, -50%);
}
.g-val {
position: absolute;
text-align: center;
left: 50%;
bottom: 0%;
width: 80px;
font-family: monospace;
font-size: calc(var(--container-size) * 50%);
color: #000000a1;
filter: drop-shadow(2px 3px 2px #00000050);
transform: translateX(-50%);
}
.g-val-ring {
position: absolute;
right: 0%;
top: 0%;
width: calc(calc(var(--container-size) * 7%) / calc(var(--container-size)/4));
height: calc(calc(var(--container-size) * 6%) / calc(var(--container-size)/4));
border-radius: 50%;
background: linear-gradient(180deg, rgba(78, 78, 78, 1) 0%, rgba(215, 215, 215, 1) 99%, rgba(236, 236, 236, 1) 100%);
}
.g-val-plate {
position: absolute;
right: 0%;
top: 0%;
width: 90%;
height: 90%;
border-radius: 50%;
background: #e4e9ee;
box-shadow: inset 0 0 15px #000000a3;
transform: translate(-5%, 5%);
}
.g-text {
position: absolute;
left: 50%;
width: 100%;
font-family: monospace;
font-size: calc(var(--container-size) * 20%);
text-align: center;
color: #000000a1;
filter: drop-shadow(2px 3px 2px #00000080);
transform: translateX(-50%);
}
.g-label {
top: 35%;
}
.g-unit {
top: 62%;
}
.g-multi{
top: 69%;
font-size: calc(var(--container-size) * 27%);
}
.g-rivets {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.g-rivet {
position: absolute;
width: 4%;
height: 4%;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.596), -1px -1px 5px rgba(0, 0, 0, 0.2);
}
.g-rivet:nth-child(1) {
top: 3%;
left: 3%;
}
.g-rivet:nth-child(2) {
top: 3%;
right: 3%;
}
.g-rivet:nth-child(3) {
bottom: 3%;
left: 3%;
}
.g-rivet:nth-child(4) {
bottom: 3%;
right: 3%;
}
.g-zone {
position: absolute;
width: 48%;
height: 48%;
top: 2%;
left: 50%;
box-sizing: border-box;
border-radius: 0 100% 0 0;
border-color: var(--hng-zone-color-normal);
border-top: calc(var(--container-size) * 2.5px) solid;
border-right: calc(var(--container-size) * 2.5px) solid;
transform-origin: bottom left;
}
.g-zone-1 {
clip-path: polygon(0% 0%, 100% 0%, 50% 0%, 0% 100%);
}
.g-zone-2 {
clip-path: polygon(0% 0%, 100% 0%, 100% 25%, 0% 100%);
}
.g-zone-3 {
clip-path: polygon(0% 0%, 100% 0%, 100% 85%, 0% 100%)
}
.g-zone.high {
border-color: var(--hng-zone-color-high);
}
.g-zone.warn {
border-color: var(--hng-zone-color-warn);
}
.g-zone.normal {
border-color: var(--hng-zone-color-normal);
}
.g-zone.low {
border-color: var(--hng-zone-color-low);
}
</style>
`
class GaugeHotnipi extends HTMLElement {
//#region ---- Class Variables ----
config = {
/** Minimum scale number */
min: 0,
/** Maximum scale number */
max: 100,
/** @type {"rect"|"round"} Gauge shape */
shape: 'rect',
/** Show the corner "rivets" */
rivets: true,
/** Show the indicator LED - lights when a value is received */
led: true,
/** @type {Array<number>} */
scales: [],
measurement: '',
unit: '',
multiplier: 0,
digits: { size: 100, distance: 14 },
zones: [],
}
/** @type {number|undefined} */
lastValue
//#endregion ---- ---- -----
constructor() {
super()
const shadow = this.attachShadow({ mode: 'open', delegatesFocus: true })
shadow.append(template.content.cloneNode(true))
this.dispatchEvent(new Event(`${componentName}:construction`, { bubbles: true, composed: true }))
this.wrapper = document.createElement('div')
shadow.appendChild(this.wrapper)
}
draw() { // eslint-disable-line sonarjs/cognitive-complexity
// scales from min - max
const gap = ((this.config.max - this.config.min) / 10)
let n = this.config.min
this.config.scales = []
for (let i = 0; i < 11; ++i) {
this.config.scales.push(n)
n = parseFloat((n + gap).toFixed(2))
}
const use = this.config.multiplier
if (use && use != 0) { // eslint-disable-line eqeqeq
this.config.scales = this.config.scales.map(n => parseFloat((n / use).toFixed(2)))
}
// clear
this.wrapper.replaceChildren()
// init wrapper
this.wrapper.style.width = '100%'
this.wrapper.style.height = '100%'
// create gauge
this.gauge = document.createElement('div')
this.gauge.setAttribute(
'style',
`--gauge-value:0;--container-size:${this.size / 50};--gn-distance:${this.config.digits.distance};--digit-size:${this.config.digits.size};--ga-tick-count:10;--ga-subtick-count:100;`
)
this.gauge.style.width = '100%'
this.gauge.style.height = '100%'
// body
const body = document.createElement('div')
body.className = 'g-body'
if (this.config.shape === 'round') {
body.classList.add('g-round')
}
this.gauge.appendChild(body)
// ring
const ring = document.createElement('div')
ring.className = 'g-ring'
body.appendChild(ring)
// rivets
if (this.config.rivets && this.config.shape === 'rect') {
const rivets = document.createElement('div')
rivets.className = 'g-rivets'
ring.appendChild(rivets)
for (let i = 0; i < 4; ++i) {
const rivet = document.createElement('div')
rivet.className = 'g-rivet'
rivets.appendChild(rivet)
}
}
// plate
const plate = document.createElement('div')
plate.className = 'g-plate'
ring.appendChild(plate)
// zones
if (this.config.zones.length > 0) {
let zone, cl
this.config.zones.forEach(z => {
zone = document.createElement('div')
cl = 'g-zone '
cl += `g-zone-${z.cover} `
cl += z.type
zone.className = cl
zone.style.rotate = `${z.rotate}deg`
plate.appendChild(zone)
})
}
// led
if (this.config.led) {
this.led = document.createElement('div')
this.led.className = 'g-led'
plate.appendChild(this.led)
}
// ticks
const ticks = document.createElement('div')
ticks.className = 'g-ticks'
plate.appendChild(ticks)
for (let i = 1; i < 12; i++) {
const tick = document.createElement('div')
tick.className = 'g-tick'
tick.setAttribute('style', '--ga-tick:' + i)
ticks.appendChild(tick)
}
for (let i = 2; i < 101; i++) {
const is = i.toString()
if (is.charAt(is.length - 1) == '1') {
continue
}
const tick = document.createElement('div')
tick.className = 'g-subtick'
tick.setAttribute('style', '--ga-tick:' + i)
ticks.appendChild(tick)
}
// numbers
const numbers = document.createElement('div')
numbers.className = 'g-nums'
plate.appendChild(numbers)
for (let i = 1; i < 12; i++) {
const num = document.createElement('div')
num.className = 'g-num'
num.setAttribute('style', '--ga-tick:' + i)
num.textContent = (this.config.scales[i - 1]).toString()
numbers.appendChild(num)
}
// measurement field
if (this.config.measurement) {
const label = document.createElement('div')
label.className = 'g-text g-label'
label.textContent = this.config.measurement
plate.appendChild(label)
}
// unit
if (this.config.unit) {
const label = document.createElement('div')
label.className = 'g-text g-unit'
label.textContent = this.config.unit
plate.appendChild(label)
}
// multiplier
if (this.config.multiplier) {
const label = document.createElement('div')
label.className = 'g-text g-multi'
label.textContent = 'x' + this.config.multiplier
plate.appendChild(label)
}
// needle
const needle = document.createElement('div')
needle.className = 'g-needle'
plate.appendChild(needle)
const needleRing = document.createElement('div')
needleRing.className = 'g-needle-ring'
plate.appendChild(needleRing)
// valueField
this.valueField = document.createElement('div')
this.valueField.className = 'g-val'
plate.appendChild(this.valueField)
this.wrapper.appendChild(this.gauge)
}
connectedCallback() {
this.draw()
}
removeBlink() {
this.led.classList.remove('active')
this.delay = null
}
setValue(value) {
this.lastValue = value
if (!this.valueField) {
return
}
const t = this.config.multiplier ? (value / this.config.multiplier).toFixed(1) : value.toFixed(1)
this.valueField.textContent = t
const v = ((value - this.config.min) / (this.config.max - this.config.min)) * 100
this.gauge.style.setProperty('--gauge-value', v)
// blink led
if (this.config.led) {
if (this.delay != null) {
clearTimeout(this.delay)
this.removeBlink()
}
this.led.classList.add('active')
this.delay = setTimeout(() => this.removeBlink(), 800)
}
}
update(value) {
this.setAttribute('gauge-value', value)
}
static get observedAttributes() {
return ['min', 'max', 'shape', 'multiplier', 'measurement', 'unit', 'rivets', 'digits', 'led', 'zones', 'gauge-value']
}
attributeChangedCallback(name, from, to) { // eslint-disable-line sonarjs/cognitive-complexity
if (from !== to) {
if (name === 'gauge-value') {
this.setValue(Number(to))
return
}
if (this.config.hasOwnProperty(name)) {
switch (name) {
case 'min':{
to = parseFloat(to)
if (isNaN(to)) {
to = 0
}
break
}
case 'max':{
to = parseFloat(to)
if (isNaN(to)) {
to = 100
}
break
}
case 'multiplier':{
to = parseFloat(to)
if (isNaN(to) || to === 0) {
to = false
}
break
}
case 'led':
case 'rivets':{
to = to == 'true' ? true : false
}
case 'digits':{ // eslint-disable-line no-fallthrough
try {
to = JSON.parse(to)
}
catch (error) {
console.log(error)
to = this.config.digits
}
break
}
case 'zones':{
try {
to = JSON.parse(to)
}
catch (error) {
console.log(error)
to = this.config.zones
}
break
}
default:
break
}
this.config[name] = to
}
}
this.size = this.wrapper.getBoundingClientRect().width
this.draw()
if (this.lastValue) {
this.setValue(this.lastValue)
}
} // --- End of attributeChangedCallback ---
} // ---- End of class definition ----
// Self-register the HTML tag
customElements.define(componentName, GaugeHotnipi)
Example usage.
hgauge.html
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="../uibuilder/images/node-blue.ico">
<title>Blank template - Node-RED uibuilder</title>
<meta name="description" content="Node-RED uibuilder - Blank template">
<!-- Your own CSS (defaults to loading uibuilders css)-->
<link type="text/css" rel="stylesheet" href="./hgauge.css" media="all">
<link type="text/css" rel="stylesheet" href="./hotnipi-gauge-style.css" media="all">
<!-- #region Supporting Scripts. These MUST be in the right order. Note no leading / -->
<script defer src="../uibuilder/uibuilder.iife.min.js"></script>
<script defer src="./hgauge.js">/* OPTIONAL: Put your custom code in that */</script>
<script defer src="./hotnipi-gauge.js">/* OPTIONAL: Put your custom code in that */</script>
<!-- #endregion -->
</head><body class="uib">
<h1 class="with-subtitle">uibuilder Blank Template</h1>
<div role="doc-subtitle">Using the IIFE library.</div>
<div id="more"><!-- '#more' is used as a parent for dynamic HTML content in examples --></div>
<div style="position:relative;width:200px;height:200px"><!-- 'gauge sizes are relative to container so width and height must be somewhere up in DOM tree -->
<!-- hot-nipi-gauge attributes:
"min" - (number, mandatory) min value
"max" - (number, mandatory) max value
"shape" - (string, optional) shape of the gauge. "round" makes gauge round shape and removes rivets, "rect" is default
"multiplier" - (number, optional) multiplier for all values. scale numbers and value are divided by that, gauge shows multiplier on plate (fe: x100)
"measurement" - (string, optional) the name of the measurement (temperature, humidity ...)
"unit" - (string, optional) the unit of the measurement
"rivets" - (boolean, optional) show/hide rivets. defaults to true
"digits" - (json string, optional) size and placement of the scale digits. '{"size":100,"distance":14}' size is treated as percentage, distance is arbitrary number around 15.
"led" - (boolean, optional) shows small led which blinks couple of times when update is received.
"zones" - (json string, optional) configuration of zones. An array of objects where:
"type" (string) - color choice. acceptable values "low", "normal", "warn", "high"
"cover" (number) - size of zone. acceptable values 1, 2 3. (1 covers space between major ticks)
"rotate" (number) - to find correct value, try with 0 and manually rotate to desired position using browser developer tools. When position found, adjust the code.
All attributes can be changed at runtime, forces redraw.
fe:
document.getElementById('gauge').setAttribute('digits',JSON.stringify({size:80,distance:15}))
document.getElementById('gauge').setAttribute('max',1200)
-->
<gauge-hotnipi id="hgauge1"
--width="100%" height="100%"
min="0" max="500"
multiplier="10"
zones='[{"type":"warn","cover":1,"rotate":55},{"type":"high","cover":2,"rotate":82}]'
measurement="Pressure"
digits='{"size":100,"distance":14}'
unit="PSI"
rivets="true"
led="true"
gauge-value="0"
></gauge-hotnipi>
</div>
</body></html>
hgauge.js
/** The simplest use of uibuilder client library
* See the docs if the client doesn't start on its own.
*/
uibuilder.onChange('msg', (msg) => {
console.log(msg)
if (msg.topic === 'hgauge1') {
console.log(msg)
let g = document.getElementById('hgauge1')
console.log(msg)
if (g) {
g.update(msg.payload)
}
}
})
hgauge.css
/* Load defaults from `<userDir>/node_modules/node-red-contrib-uibuilder/front-end/uib-brand.css`
* This version auto-adjusts for light/dark browser settings but might not be as complete.
*/
@import url("../uibuilder/uib-brand.css");
/* Box sizing rules */
/* @namespace ct "http://gionkunz.github.com/chartist-js/ct"; */
/* *,
*::before,
*::after {
box-sizing: border-box;
} */
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
/* ul[role='list'],
ol[role='list'] {
list-style: none;
} */
/* A elements that don't have a class get default styles */
/* a:not([class]) {
text-decoration-skip-ink: auto;
} */
/* Make images easier to work with */
/* img,
picture {
max-width: 100%;
display: block;
} */
/* Inherit fonts for inputs and buttons */
/* input,
button,
textarea,
select {
font: inherit;
} */
/* Remove all animations, transitions and smooth scroll for people that prefer not to see them */
/* @media (prefers-reduced-motion: reduce) {
html:focus-within {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
} */