Ghost thermostat for uibuilder?

I use the excellent Ghost thermostat on my dashboard but am developing my heating control system using uibuilder and was hoping if someone can guide me how to get the ghost thermostat working on my uibuilder?

Thanks guys

Hi, just back from hols, been away for a week with no computer travelling through the wonderful Baltic Republics.

At first glance, it looks like the ghost thermostat simply uses a ui_template so hopefully it shouldn't be too hard to unpick. I need some time to look at it though. I'll update this thread when I get a chance to look.

2 Likes

thanks @TotallyInformation

Not forgotten this. However, it is rather more complex to translate than anticipated.

Really, it needs turning into an HTML Component for proper reuse. But in the meantime, I need to go through and translate a lot of the manually created CSS elements to use CSS variables.

thank you for even looking @TotallyInformation is is very much appreciated

Not quite there yet but certainly getting there.


image

It is implemented as a pure web component and so could be used without Node-RED or uibuilder as well. I've also removed the requirement for Font Awesome (using just emoji's instead). The main display switches work fine (and much simplified over the original). However, none of the clever things like the outer ring colour changes are implemented yet.

In your HTML, it looks like this:

<ghost-thermometer temperature="36" setpoint="12" mode="heating"></ghost-thermometer>
<ghost-thermometer temperature="17" setpoint="5" mode="off" style="width:25%;"></ghost-thermometer>
<ghost-thermometer style="width:25%;"></ghost-thermometer>

But you can also control things from JavaScript as well, such as this:

// Set the temperature
$('#gt-1').temperature = '15'
// Get the temperature
console.log( $('#gt-1').temperature )

Zero-code updates from Node-RED also easy, here using the uib-update node:

I will be adding this to my small collection of pure web components which live here: TotallyInformation/web-components. Not there yet but that's enough for tonight.

I can give you enough code to try it manually if you feel the need.

3 Likes

@TotallyInformation thank you for your efforts so far, the coloured ring i do not really use any way.
But would love the code so far please.

OK,

ghost-thermometer.js

Put this in the src folder along side your index.htm file.

/** A zero dependency web component that will display a circular thermometer display and controller for heating systems.
 * Based on @ghostmaster75's Node-RED Dashboard Widget of the same name: https://flows.nodered.org/flow/9ca3a19e0e2ff606bd64f1e73a2191eb
 *
 * See ./docs/ghost-thermometer.md for detailed documentation on installation and use.
 *
 * @version: 0.0.1 2023-08-06
 *
 * See https://github.com/runem/web-component-analyzer#-how-to-document-your-components-using-jsdoc on how to document
 *
 * Use `npx web-component-analyzer ./components/ghost-thermometer.js` to create/update the documentation
 *     or paste into https://runem.github.io/web-component-analyzer/
 * Use `npx web-component-analyzer ./components/*.js --format vscode --outFile ./vscode-descriptors/ti-web-components.html-data.json`
 *     to generate/update vscode custom data files. See https://github.com/microsoft/vscode-custom-data/tree/main/samples/webcomponents
 *
 * To Do:
 * -
 */
/*
  Copyright (c) 2023 Julian Knight (Totally Information)

  Licensed under the Apache License, Version 2.0 (the "License");
  you may not use this file except in compliance with the License.
  You may obtain a copy of the License at

  http://www.apache.org/licenses/LICENSE-2.0

  Unless required by applicable law or agreed to in writing, software
  distributed under the License is distributed on an "AS IS" BASIS,
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  See the License for the specific language governing permissions and
  limitations under the License.
*/

const componentName = 'ghost-thermometer'
const className = 'GhostThermometer'

const template = document.createElement('template')
template.innerHTML = /** @type {HTMLTemplateElement} */ /*html*/`
    <style>
        :host {
            display: inline-block; /* default is inline */
            contain: content; /* performance boost */
            max-width: 400px; width:90%;
        }

        svg {
            transition: all .6s cubic-bezier(0.175, 0.885, 0.32, 1.2);
        }

        stop {
            transition: all .5s;
        }

        .led {
            -webkit-transition: all 0.5s;
            transition: all 0.5s;
            fill: url(#ledColor);
        }

        .dial {
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
        }
        .qGradient {
        fill : url(#eGradient);
        }
        .qGradientT {
            fill : url(#eGradient);
        }
        .eGradient {
            fill : url(#eGradient);
        }
        .lbl {
            text-anchor: middle;
            fill : #ffffff;
            clip-path: url(#qClip);
        }
        .lblDial {
            fill: #dddddd;
        }

        .lblAmbient {
            font-weight: 400;
            clip-path: url(#qClip);
        }

        .lblAmbient tspan {
            font-weight: 400;
        }

        .lblTarget {
            font-weight: 400;
            fill: orange;
        }

        .lblTarget tspan {
            font-weight: 400;
            fill: orange;
            clip-path: url(#qClip);
        }    

        .nodisplay {
            display: none !important;
        }

        .animate {
            transition: all 0.5s;
        }
    </style>
    <div>
        <slot></slot>
        <svg width="100%" height="100%" viewBox="0 0 400 400" class="dial">
                <defs>
                    <linearGradient id="qGradient" gradientTransform="rotate(65)">
                        <stop offset="50%" stop-color="rgb(86,89,94)"></stop>
                        <stop offset="65%" stop-color="rgb(30,30,30)"></stop>
                    </linearGradient>
                    <linearGradient id="qGradientT" gradientTransform="rotate(65)">
                        <stop offset="55%" stop-color="#3b3e43" stop-opacity="1"></stop>
                        <stop offset="90%" stop-color="rgb(0,0,0)" stop-opacity="1"></stop>
                    </linearGradient>
                    <clipPath id="qClip">
                        <circle cx="200" cy="200" r="175"></circle>
                    </clipPath>
                    <radialGradient id="ledColor" cx="50%" cy="50%" r="95%" fx="50%" fy="50%">
                        <stop offset="45%" stop-color="rgb(143,141,141)" stop-opacity="1"></stop> <!-- Heating -->
                        <!-- <stop offset="45%" stop-color="rgb(81,170,214)" stop-opacity="1"></stop> --> <!-- Cooling -->
                        <!-- <stop offset="45%" stop-color="rgb(125,128,0)" stop-opacity="1"></stop> --> <!-- Off -->
                        <stop offset="65%" stop-color="rgb(0,0,0)" stop-opacity="1"></stop>
                    </radialGradient>
                    <linearGradient id="eGradient" gradientTransform="rotate(55)">
                        <stop offset="55%" stop-color="#888888" stop-opacity="1"></stop>
                        <stop offset="95%" stop-color="#333333" stop-opacity="1"></stop>
                    </linearGradient>
                </defs>
                <circle cx="200" cy="200" r="200" class="eGradient"></circle>
                <circle cx="200" cy="200" r="197" stroke="black" stroke-width="1" class="led"></circle>
                <circle cx="200" cy="200" r="180" class="qGradient"></circle>
                <circle cx="200" cy="200" r="175" class="qGradient"></circle>
                <text x="200" y="70" class="lbl lblDial" id="lblMain">
                    AMBIENT
                </text>
                <text x="200" y="210" font-size="160" class="lbl lblAmbient" id="valMain">
                    --
                </text>
                <line x1="55" y1="235" x2="345" y2="235" stroke="#DDDDDD" stroke-width="1" opacity="0.8"></line>
                <text x="125" y="275" class="lbl lblDial" id="lblLeft">
                    SET
                </text>
                <text x="125" y="315" font-size="35" class="lbl lblTarget" id="valLeft">
                    --
                </text>
                <text x="275" y="275" class="lbl lblDial" id="lblRight">
                    MODE
                </text>
                <text x="275" y="315" font-size="35" class="lbl lblTarget icon"  id="valRight" style="fill: orange;">
                    ⛔
                </text>
                <g>
                    <rect opacity="0" width="350" height="200" x="25" y="30" id="clickMain" fill="yellow">
                </g>
                <g transform="translate(200,200)">
                    <path d="M0,40 L0,175   A175,175 0 0,1 -175,40    z" fill="blue" opacity="0" id="btnLeft"></path>
                    <path d="M0,40 L175,40   A175,175 0 0,1    0,175  z" fill="red" opacity="0" id="btnRight"></path>
                </g>
            </svg>
        </div>
`

/** A Zero dependency button web component that will display a circular thermometer display and controller for heating systems.
 *  Contains relevant data from data-*, topic and payload attributes (or properties),
 *  includes a _meta object showing whether any modifier keys were used, the element id/name
 *
 * @element button-send
 *
 * @fires button-send:click - Document object event. evt.details contains the data
 * @fires {function} uibuilder.send - Sends a msg back to Node-RED if uibuilder available. topic, payload and _meta props may all be set.
 *
 * @attr {string} topic - Optional. Topic string to use. Mostly for node-red messages
 * @attr {string} payload - Optional. Payload string. Mostly for node-red messages. For non-string payload, see props below
 * @attr {string} id - Optional. HTML ID, must be unique on page. Included in output _meta prop.
 * @attr {string} name - Optional. HTML name attribute. Included in output _meta prop.
 * @attr {string} data-* - Optional. All data-* attributes are returned in the _meta prop as a _meta.data object.
 *
 * @prop {any|string} payload - Can be an attribute or property. If used as property, must not use payload attribute in html, allows any data to be attached to payload. As an attribute, allows a string only.
 * @prop {string} topic - Optional. Topic string to use. Mostly for node-red messages
 * @prop {Array<string>} props - List of watched HtML Attributes
 *
 * @slot default - Button label. Allows text, inline and most block tags to be included (unlike the standard button tag which only allows inline tags).
 *
 * @csspart button - Uses the uib-styles.css uibuilder master for variables where available.
 *
 * See https://github.com/runem/web-component-analyzer#-how-to-document-your-components-using-jsdoc on how to document
 */
export default class GhostThermometer extends HTMLElement {
    //#region --- Class Properties ---

    /** @type {string} topic - Optional. Topic string to use. Mostly for node-red messages */
    topic = ''
    /** @type {any|string} payload - Can be an attribute or property. If used as property, must not use payload attribute in html, allows any data to be attached to payload. As an attribute, allows a string only. */
    payload = ''
    /** What is the current display mode? */
    displayMode = 'default'
    /** The current (ambient) */
    // temperature = '--'
    /** The setpoint temperature */
    // setpoint = '--'
    // mode = 'off'

    modes = {
        heating: {
            label: 'heating',
            icon: '🔥',
            color: 'orange'
        },
        cooling: {
            label: 'cooling',
            icon: '❄️',
            color: 'rgb(81,170,214)'
        },
        off: {
            label: 'off',
            icon: '⛔',
            color: 'rgb(230,0,0)'
        },
        // away: {
        //     label: 'away',
        //     icon: '\uf1ce',
        //     color: 'gray'
        // },
    }

    /** Holds a count of how many instances of this component are on the page */
    static _iCount = 0
    /** @type {Array<string>} List of all of the html attribs (props) listened to */
    static props = ['topic', 'payload', 'name', 'temperature', 'setpoint', 'mode']

    //#endregion --- Class Properties ---

    //#region ---- Utility Functions ----

    /** Mini jQuery-like shadow dom selector
     * @param {string} selection HTML element selector
     * @returns {HTMLElement | null} The discovered element
     */
    $(selection) {
        return this.shadowRoot && this.shadowRoot.querySelector(selection)
    }

    str2bool(strvalue) {
        return (strvalue && typeof strvalue === 'string') ? (strvalue.toLowerCase() === 'true') : (strvalue === true)
    }

    //#endregion ---- ---- ---- ----

    //#region ---- Event Handlers ----

    /** Handle a `uibuilder:msg:_ui:update:${this.id}` custom event
     * @param {CustomEvent} evt uibuilder `uibuilder:msg:_ui:update:${this.id}` custom event evt.details contains the data
     */
    _uibMsgHandler(evt) {
        // If there is a payload, we want to replace the slot - easiest done from the light DOM
        // if ( evt['detail'].payload ) {
        //     const el = document.getElementById(this.id)
        //     el.innerHTML = evt['detail'].payload
        // }
        // If there is a payload, we want to replace the VALUE
        // if ( evt['detail'].payload ) {
        //     const el = this.shadowRoot.getElementById('value')
        //     el.innerHTML = evt['detail'].payload
        // }
    }

    _btnLeftClick(evt) {
        console.log('clicked the left button')
        if (this.displayMode === 'default' ) {
            // Switch to set mode
            this.displayMode = 'set'
            this.$('#lblMain').childNodes[0].nodeValue = 'SET'
            this.$('#valMain').childNodes[0].nodeValue = this.setpoint || '--'
            this.$('#lblLeft').childNodes[0].nodeValue = ''
            this.$('#valLeft').childNodes[0].nodeValue = '-'
            this.$('#lblRight').childNodes[0].nodeValue = ''
            this.$('#valRight').childNodes[0].nodeValue = '+'
        } else {
            // decrement set temperature
        }
    }

    _btnRightClick(evt) {
        console.log('clicked the right button')

        if (this.displayMode === 'default' ) {
            // Switch to mode change mode
            this.displayMode = 'mode'
            this.$('#lblMain').childNodes[0].nodeValue = 'MODE'
            this.$('#valMain').childNodes[0].nodeValue = this.modes[this.mode].icon || '⚠️'
            this.$('#lblLeft').childNodes[0].nodeValue = ''
            this.$('#valLeft').childNodes[0].nodeValue = '<'
            this.$('#lblRight').childNodes[0].nodeValue = ''
            this.$('#valRight').childNodes[0].nodeValue = '>'
        } else {
            // increment set temperature
        }
    }

    _clickMain(evt) {
        console.log('clicked main')
        if (this.displayMode !== 'default' ) {
            this.displayMode = 'default'
            this.$('#lblMain').childNodes[0].nodeValue = 'AMBIENT'
            this.$('#valMain').childNodes[0].nodeValue = this.temperature || '--'
            this.$('#lblLeft').childNodes[0].nodeValue = 'SET'
            this.$('#valLeft').childNodes[0].nodeValue = this.setpoint || '--'
            this.$('#lblRight').childNodes[0].nodeValue = 'MODE'
            this.$('#valRight').childNodes[0].nodeValue = this.modes[this.mode].icon || '⚠️'
        }
    }

    //#endregion ---- ---- ---- ----

    //#region --- Getters/Setters ---

    get temperature() {
        return this.getAttribute('temperature') || '--'
    }

    set temperature(value) {
        this.setAttribute('temperature', value || '--')
    }

    get setpoint() {
        return this.getAttribute('setpoint') || '--'
    }

    set setpoint(value) {
        this.setAttribute('setpoint', value || '--')
    }

    get mode() {
        return this.getAttribute('mode') || 'off'
    }

    set mode(value) {
        this.setAttribute('mode', value || 'off')
    }

    //#endregion --- --- ---
    constructor() {
        super()
        this.attachShadow({ mode: 'open', delegatesFocus: true })
            .append(template.content.cloneNode(true))

        this._data = { ...this.dataset } // All of the data-* attributes as an object
        this._name = this.getAttribute('name')
        this._msg = {
            'topic': this.topic,
            'payload': this.payload ? this.payload : this._data,
            '_meta': {
                id: this.id,
                name: this._name,
                data: this._data, // All of the data-* attributes as an object
            }
        }

        this._clickEvt = new CustomEvent('button-send:click', { 'detail': this._msg })
        this.dispatchEvent(new Event(`${componentName}:construction`, { bubbles: true, composed: true }))

        // Get a reference to the (optional) uibuilder FE client library if possible
        try {
            this.uibuilder = window['uibuilder']
        } catch (e) {
            this.uibuilder = undefined
        }

        // this.addEventListener('click', evt => {
        //     evt.preventDefault()
        //     this._msg._meta = {
        //         id: this.id,
        //         name: this._name,
        //         data: this._data, // All of the data-* attributes as an object
        //         altKey: evt.altKey,
        //         ctrlKey: evt.ctrlKey,
        //         shiftKey: evt.shiftKey,
        //         metaKey: evt.metaKey,
        //     }
        //     document.dispatchEvent(this._clickEvt)
        //     this.uibuilder.send(this._msg)
        // })

    }

    static get observedAttributes() {
        return this.props
    }

    attributeChangedCallback(attrib, oldVal, newVal) {

        if ( oldVal === newVal ) return

        this[attrib] = newVal

        // console.log('>> attrib change >>', attrib, newVal)

        switch (attrib) {
            case 'temperature': {
                if (this.displayMode === 'default' ) this.$('#valMain').childNodes[0].nodeValue = this.temperature || '--'
                break
            }

            case 'setpoint': {
                if (this.displayMode === 'default' ) this.$('#valLeft').childNodes[0].nodeValue = this.setpoint || '--'
                else if (this.displayMode === 'set' ) this.$('#valMain').childNodes[0].nodeValue = this.setpoint || '--'
                break
            }

            case 'mode': {
                if (this.displayMode === 'default' ) this.$('#valRight').childNodes[0].nodeValue = this.modes[this.mode].icon || '⚠️'
                else if (this.displayMode === 'mode' ) this.$('#valMain').childNodes[0].nodeValue = this.modes[this.mode].icon || '⚠️'
                break
            }

            default: {
                break
            }
        }

    } // --- end of attributeChangedCallback --- //

    // when the component is added to doc
    connectedCallback() {
        ++GhostThermometer._iCount

        // Make sure instance has an ID. Create an id from name or calculation if needed
        this.name = this.getAttribute('name')
        if (!this.id) {
            if (this.name) this.id = this.name.toLowerCase().replace(/\s/g, '_')
            else this.id = `gt-${GhostThermometer._iCount}`
        }

        // Listen for a uibuilder msg that is targetted at this instance of the component
        document.addEventListener(`uibuilder:msg:_ui:update:${this.id}`, this._uibMsgHandler.bind(this))

        // Add click event handlers
        this.$('#btnLeft').onclick = this._btnLeftClick.bind(this)
        this.$('#btnRight').onclick = this._btnRightClick.bind(this)
        this.$('#clickMain').onclick = this._clickMain.bind(this)
    }

    // Runs when an instance is removed from the DOM
    disconnectedCallback() {
        // NB: Dont decrement GhostThermometer._iCount because that could lead to id nameclashes

        // @ts-ignore
        document.removeEventListener(`uibuilder:msg:_ui:update:${this.id}`, this._uibMsgHandler)
    }

} // ---- End of DefinitionList class definition ---- //

/** Self register the class to global
 * Enables new widgets to be dynamically added via JS
 * and lets the static methods be called
 */
window[className] = GhostThermometer

// Add the class as a new Custom Element to the window object
customElements.define(componentName, GhostThermometer)

index.html

This is a variation on the default template. Note that you need to use the ESM version of the uibuilder client library for web components to work and so you need a relatively modern browser (anything from early 2019 onwards and some earlier browsers should work fine).

<!doctype html>
<html lang="en" class="dark"><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>Ghost Thermometer - Node-RED uibuilder</title>
    <meta name="description" content="Node-RED uibuilder - Ghost Thermometer">

    <!-- Your own CSS (defaults to loading uibuilders css)-->
    <link type="text/css" rel="stylesheet" href="./index.css" media="all">

    <!-- #region Supporting Scripts. These MUST be in the right order. Note no leading / -->
    <script type="module" async src="./index.js">/* OPTIONAL: Put your custom code in that */</script>
    <!-- #endregion -->

</head><body class="uib">
    
    <h1 class="with-subtitle">Ghost Thermometer</h1>
    <div role="doc-subtitle">Using the uibuilder ESM library & Totally Info Web Components.</div>

    <div id="more"><!-- '#more' is used as a parent for dynamic HTML content in examples --></div>

    <ghost-thermometer temperature="36" setpoint="12" mode="heating"></ghost-thermometer>

    <ghost-thermometer temperature="17" setpoint="5" mode="off" style="width:25%;"></ghost-thermometer>
    <ghost-thermometer style="width:25%;"></ghost-thermometer>

</body></html>

You can provide your own ids to the components but if you don't, they are automatically assigned id's in the format gt-1, gt-2, etc in the order on-page. Those can be used in uib-update nodes for zero-code updates as in the example flow below.

index.js

import '../uibuilder/uibuilder.esm.min.js'
import './ghost-thermometer.js'

You can access your widgets from within the index.js code if you like to do things in the client code. $('#gt-1').temperature = '15' for example would set the first widget to 15. Or you can simply control everything from Node-RED as in the example below.

[{"id":"90f0d7c7bdc13be7","type":"debug","z":"58deab1ab77e14d0","name":"debug 122","active":true,"tosidebar":true,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"","statusType":"counter","x":815,"y":380,"wires":[],"l":false},{"id":"66cb4e5cfab6f15b","type":"uibuilder","z":"58deab1ab77e14d0","name":"","topic":"","url":"ghost","fwdInMessages":false,"allowScripts":false,"allowStyles":false,"copyIndex":true,"templateFolder":"blank","extTemplate":"","showfolder":false,"reload":false,"sourceFolder":"src","deployedVersion":"6.5.0","showMsgUib":false,"title":"","descr":"","x":600,"y":380,"wires":[["90f0d7c7bdc13be7"],[]]},{"id":"7b6a667b902fcedf","type":"inject","z":"58deab1ab77e14d0","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"temperature\":26,\"setpoint\":27,\"mode\":\"cooling\"}","payloadType":"json","x":270,"y":380,"wires":[["6e3c7a4291f47720"]]},{"id":"6e3c7a4291f47720","type":"uib-update","z":"58deab1ab77e14d0","name":"","topic":"","mode":"update","modeSourceType":"update","cssSelector":"#gt-1","cssSelectorType":"str","slotSourceProp":"","slotSourcePropType":"msg","attribsSource":"payload","attribsSourceType":"msg","slotPropMarkdown":false,"x":430,"y":380,"wires":[["66cb4e5cfab6f15b"]]},{"id":"37e9c67f03fe1a37","type":"inject","z":"58deab1ab77e14d0","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"temperature\":126.3,\"setpoint\":27,\"mode\":\"cooling\"}","payloadType":"json","x":270,"y":440,"wires":[["ab249ae8a8716647"]]},{"id":"ab249ae8a8716647","type":"uib-update","z":"58deab1ab77e14d0","name":"","topic":"","mode":"update","modeSourceType":"update","cssSelector":"#gt-3","cssSelectorType":"str","slotSourceProp":"","slotSourcePropType":"msg","attribsSource":"payload","attribsSourceType":"msg","slotPropMarkdown":false,"x":430,"y":440,"wires":[["66cb4e5cfab6f15b"]]}]

Don't forget to make the above file changes to the uibuilder node in this example.

2 Likes

Great job, Julian!

1 Like

Fantastic as always! Thank you

1 Like

Here is an updated version. The LED now works as does the mode change. In addition, when used with uibuilder, it automatically sends a message back to Node-RED/uibuilder when the switchState property changes. That happens when the LED colour changes. Note that, unlike the original Dashboard version, this one does not spam you with messages if you change the setpoint.

There is also an error/warning display added. The web component self-creates a unique ID if you haven't added one yourself. This is so that you can make sense of any messages back to Node-RED and can also control it from Node-RED using the uibuilder zero- and low-code features. But it is still fully usable without uibuilder (though I still need to add some custom events to make it easier to listen to changes if doing front-end coding with it).

The output is a bit different to the Dashboard version. The msg.payload looks like:

{
   "id":"ghostthermometer-1",
   "temperature":12,
   "switchState":"off",
   "mode":"heating",
   "setpoint":12
}

ghost-thermometer.js

/** A zero dependency web component that will display a circular thermometer display and controller for heating systems.
 * Based on @ghostmaster75's Node-RED Dashboard Widget of the same name: https://flows.nodered.org/flow/9ca3a19e0e2ff606bd64f1e73a2191eb
 *
 * See ./docs/ghost-thermometer.md for detailed documentation on installation and use.
 *
 * @version: 0.0.2 2023-08-12
 *
 * See https://github.com/runem/web-component-analyzer#-how-to-document-your-components-using-jsdoc on how to document
 *
 * Use `npx web-component-analyzer ./components/ghost-thermometer.js` to create/update the documentation
 *     or paste into https://runem.github.io/web-component-analyzer/
 * Use `npx web-component-analyzer ./components/*.js --format vscode --outFile ./vscode-descriptors/ti-web-components.html-data.json`
 *     to generate/update vscode custom data files. See https://github.com/microsoft/vscode-custom-data/tree/main/samples/webcomponents
 *
 * To Do:
 * -
 */
/*
  Copyright (c) 2023 Julian Knight (Totally Information)

  Licensed under the Apache License, Version 2.0 (the "License");
  you may not use this file except in compliance with the License.
  You may obtain a copy of the License at

  http://www.apache.org/licenses/LICENSE-2.0

  Unless required by applicable law or agreed to in writing, software
  distributed under the License is distributed on an "AS IS" BASIS,
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  See the License for the specific language governing permissions and
  limitations under the License.
*/

const componentName = 'ghost-thermometer'
const className = 'GhostThermometer'

/** Properly round a floating point number (stupid JavaScript!)
 * @param {number} number The number to round
 * @param {number} digits The required decimal places to round to (default=0)
 * @returns {number} Input number rounded to requested DP's
 */
function floatRound(number, digits = 0) {
    const multiple = Math.pow(10, digits)
    return Math.round(number * multiple) / multiple
}

const template = document.createElement('template')
template.innerHTML = /** @type {HTMLTemplateElement} */ /*html*/`
    <style>
        :host {
            display: inline-block; /* default is inline */
            contain: content; /* performance boost */
            max-width: 400px; width:90%;

            --grey1-color: hsl(0, 0%, 87%); /* #dddddd; Divider line, dial label */
            --grey2-color: hsl(0, 0%, 53%); /* #888888 */ 
            --grey3-color: hsl(0, 0%, 20%); /* #333333 */
            --grey4-color: hsl(0, 0%, 12%); /* rgb(30,30,30) */
            --dark-color: hsl(0, 0%, 0%); /* black; */
            --grey-blue-color: hsl(218, 4%, 35%); /* rgb(86,89,94) grey-blue */
            /* --grey-blue-color2: hsl(218, 6%, 25%); #3b3e43 grey-blue2 */
            --text-color: hsl(0, 0%, 100%); /* #ffffff; */
            --warn-color: hsl(39, 100%, 50%); /* orange; */
            --off-color: hsl(0, 1%, 56%); /* rgb(143,141,141) off */
            --cooling-color: hsl(200, 62%, 58%); /* rgb(81,170,214) cooling, light blue */
            --heating-color: hsl(30, 100%, 50%); /* rgb(125,128,0) heating, dark orange */
        }

        svg {
            transition: all .6s cubic-bezier(0.175, 0.885, 0.32, 1.2);
        }

        stop {
            transition: all .5s;
        }

        .led {
            -webkit-transition: all 0.5s;
            transition: all 0.5s;
            fill: url(#ledColor);
        }

        .dial {
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
        }

        .lbl {
            text-anchor: middle;
            fill : var(--text-color);
            clip-path: url(#qClip);
        }
        .lblDial {
            fill: var(--grey1-color);
        }

        .valMain {
            font-weight: 400;
            /* clip-path: url(#qClip); */
        }

        .lblAmbient tspan {
            font-weight: 400;
        }

        .lblTarget {
            font-weight: 400;
            fill: var(--warning-intense, --warn-color);
        }

        .lblTarget tspan {
            font-weight: 400;
            fill: var(--warning-intense, --warn-color);
            clip-path: url(#qClip);
        }    

        .nodisplay {
            display: none !important;
        }

        .animate {
            transition: all 0.5s;
        }
    </style>
        <svg 
            width="100%" height="100%" viewBox="0 0 400 400" class="dial"
            xmlns="http://www.w3.org/2000/svg"
            xmlns:xlink="http://www.w3.org/1999/xlink"
        >
            <defs>
                <linearGradient id="qGradient" gradientTransform="rotate(65)">
                    <stop offset="50%" stop-color="var(--grey-blue-color)"></stop>
                    <stop offset="65%" stop-color="var(--grey4-color)"></stop>
                </linearGradient>
                <!-- <linearGradient id="qGradientT" gradientTransform="rotate(65)">
                    <stop offset="55%" stop-color="#3b3e43" stop-opacity="1"></stop>
                    <stop offset="90%" stop-color="var(--dark-color)" stop-opacity="1"></stop>
                </linearGradient> -->
                <clipPath id="qClip">
                    <circle cx="200" cy="200" r="175"></circle>
                </clipPath>
                <radialGradient id="ledColor" cx="50%" cy="50%" r="95%" fx="50%" fy="50%">
                    <stop offset="45%" stop-color="var(--off-color)" stop-opacity="1"></stop> <!-- Heating -->
                    <stop offset="65%" stop-color="var(--dark-color)" stop-opacity="1"></stop>
                </radialGradient>
                <linearGradient id="eGradient" gradientTransform="rotate(55)">
                    <stop offset="55%" stop-color="var(--grey2-color)" stop-opacity="1"></stop>
                    <stop offset="95%" stop-color="var(--grey3-color)" stop-opacity="1"></stop>
                </linearGradient>
            </defs>
            <circle cx="200" cy="200" r="200" fill="url(#eGradient)"></circle>
            <circle cx="200" cy="200" r="197" stroke="var(--dark-color)" stroke-width="1" class="led"></circle>
            <circle cx="200" cy="200" r="180" fill="url(#qGradient)"></circle>
            <circle cx="200" cy="200" r="175" fill="url(#qGradient)"></circle>
            <text x="200" y="70" class="lbl lblDial" id="lblMain">
                AMBIENT
            </text>
            <text x="200" y="210" font-size="140" class="lbl valMain" id="valMain">
                --
            </text>
            <line x1="55" y1="235" x2="345" y2="235" stroke="var(--grey1-color)" stroke-width="1" opacity="0.8"></line>
            <text x="200" y="254" class="lbl" id="lblNote"> </text>
            <text x="125" y="285" class="lbl lblDial" id="lblLeft">
                SET
            </text>
            <text x="125" y="325" font-size="35" class="lbl lblTarget" id="valLeft">
                --
            </text>
            <text x="275" y="285" class="lbl lblDial" id="lblRight">
                MODE
            </text>
            <text x="275" y="325" font-size="35" class="lbl lblTarget icon" id="valRight">
                ⛔
            </text>
            <g>
                <rect opacity="0" width="350" height="200" x="25" y="30" id="clickMain">
                    <title id="titleMain">Current ambient temperature</title>
                </rect>
            </g>
            <g transform="translate(200,200)">
                <path d="M0,40 L0,175 A175,175 0 0,1 -175,40 z" opacity="0" id="btnLeft">
                    <title id="titleLeft">Current setpoint. Click to change</title>
                </path>
                <path d="M0,40 L175,40 A175,175 0 0,1 0,175 z" opacity="0" id="btnRight">
                    <title id="titleRight">Current mode. Click to change</title>
                </path>
            </g>
        </svg>
        <slot></slot>
`

/** A Zero dependency button web component that will display a circular thermometer display and controller for heating systems.
 *  Contains relevant data from data-*, topic and payload attributes (or properties),
 *  includes a _meta object showing whether any modifier keys were used, the element id/name
 *
 * @element button-send
 *
 * @fires button-send:click - Document object event. evt.details contains the data
 * @fires {function} uibuilder.send - Sends a msg back to Node-RED if uibuilder available. topic, payload and _meta props may all be set.
 *
 * @attr {string} topic - Optional. Topic string to use. Mostly for node-red messages
 * @attr {string} payload - Optional. Payload string. Mostly for node-red messages. For non-string payload, see props below
 * @attr {string} id - Optional. HTML ID, must be unique on page. Included in output _meta prop.
 * @attr {string} name - Optional. HTML name attribute. Included in output _meta prop.
 * @attr {string} data-* - Optional. All data-* attributes are returned in the _meta prop as a _meta.data object.
 *
 * @prop {any|string} payload - Can be an attribute or property. If used as property, must not use payload attribute in html, allows any data to be attached to payload. As an attribute, allows a string only.
 * @prop {string} topic - Optional. Topic string to use. Mostly for node-red messages
 * @prop {Array<string>} props - List of watched HtML Attributes
 *
 * @slot default - Button label. Allows text, inline and most block tags to be included (unlike the standard button tag which only allows inline tags).
 *
 * @csspart button - Uses the uib-styles.css uibuilder master for variables where available.
 *
 * See https://github.com/runem/web-component-analyzer#-how-to-document-your-components-using-jsdoc on how to document
 */
export default class GhostThermometer extends HTMLElement {
    //#region --- Class Properties ---

    /** @type {string} topic - Optional. Topic string to use. Mostly for node-red messages */
    topic = ''
    /** @type {any|string} payload - Can be an attribute or property. If used as property, must not use payload attribute in html, allows any data to be attached to payload. As an attribute, allows a string only. */
    payload = ''
    /** What is the current display mode? */
    displayMode = 'default'

    // numTemperature = 0
    // numSetpoint = 0
    switchState = 'off'

    modeList = ['heating', 'cooling', 'off']
    modes = {
        heating: {
            label: 'heating',
            icon: '🔥',
            color: 'orange'
        },
        cooling: {
            label: 'cooling',
            icon: '❄️',
            color: 'rgb(81,170,214)'
        },
        off: {
            label: 'off',
            icon: '⛔',
            color: 'rgb(230,0,0)'
        },
        // away: {
        //     label: 'away',
        //     icon: '\uf1ce',
        //     color: 'gray'
        // },
    }

    /** Holds a count of how many instances of this component are on the page */
    static _iCount = 0
    /** @type {Array<string>} List of all of the html attribs (props) listened to */
    static props = ['topic', 'payload', 'name', 'id', 'temperature', 'setpoint', 'mode']

    //#endregion --- Class Properties ---

    //#region ---- Utility Functions ----

    /** Mini jQuery-like shadow dom selector
     * @param {string} selection HTML element selector
     * @returns {HTMLElement | null} The discovered element
     */
    $(selection) {
        return this.shadowRoot && this.shadowRoot.querySelector(selection)
    }

    /** Convert a string 'true' or 'false' to a boolean true/false
     * @param {*} strvalue The string representation of the boolean
     * @returns {boolean}
     */
    str2bool(strvalue) {
        return (strvalue && typeof strvalue === 'string') ? (strvalue.toLowerCase() === 'true') : (strvalue === true)
    }

    /** Set the note label if required
     * @param {string} note The text to display
     * @param {HTMLElement} lblNote Reference to the SVG <text> element containing the text
     */
    doNote(note, lblNote) {
        if ( note === '' ) note = ' '
        lblNote.childNodes[0].nodeValue = note
    }

    /** uibuilder send */
    uibSend() {
        if (!window['uibuilder']) return
        const uib = window['uibuilder']
        uib.send({
            payload: {
                id: this.id,
                temperature: this.numTemperature,
                switchState: this.switchState,
                mode: this.mode,
                setpoint: this.numSetpoint,
            }
        })
    }

    /** When the temp or setpoint changes, check the heating/cooling mode and change if needed */
    checkMode() {
        if ( !this.numTemperature || !this.numSetpoint ) return

        if ( this.mode === 'heating' && this.numSetpoint > this.numTemperature ) {
            this.$('#ledColor > stop:nth-child(1)').setAttribute('stop-color', 'var(--heating-color)')
            if ( this.switchState !== 'heating') {
                this.switchState = 'heating'
                this.uibSend()
            }
        } else if ( this.mode === 'cooling' && this.numSetpoint < this.numTemperature ) {
            this.$('#ledColor > stop:nth-child(1)').setAttribute('stop-color', 'var(--cooling-color)')
            if ( this.switchState !== 'cooling') {
                this.switchState = 'cooling'
                this.uibSend()
            }
        } else {
            this.$('#ledColor > stop:nth-child(1)').setAttribute('stop-color', 'var(--off-color)')
            if ( this.switchState !== 'off') {
                this.switchState = 'off'
                this.uibSend()
            }
        }
    }

    //#endregion ---- ---- ---- ----

    //#region ---- Event Handlers ----

    /** Handle a `uibuilder:msg:_ui:update:${this.id}` custom event
     * @param {CustomEvent} evt uibuilder `uibuilder:msg:_ui:update:${this.id}` custom event evt.details contains the data
     */
    _uibMsgHandler(evt) {
        // If there is a payload, we want to replace the slot - easiest done from the light DOM
        // if ( evt['detail'].payload ) {
        //     const el = document.getElementById(this.id)
        //     el.innerHTML = evt['detail'].payload
        // }
        // If there is a payload, we want to replace the VALUE
        // if ( evt['detail'].payload ) {
        //     const el = this.shadowRoot.getElementById('value')
        //     el.innerHTML = evt['detail'].payload
        // }
    }

    _btnLeftClick(evt) {
        if (this.displayMode === 'default' ) {
            // Switch to set mode
            this.displayMode = 'set'
            this.$('#lblMain').childNodes[0].nodeValue = 'SET'
            this.$('#valMain').childNodes[0].nodeValue = this.setpoint || '--'
            this.$('#lblLeft').childNodes[0].nodeValue = ''
            this.$('#valLeft').childNodes[0].nodeValue = '-'
            this.$('#lblRight').childNodes[0].nodeValue = ''
            this.$('#valRight').childNodes[0].nodeValue = '+'

        } else if (this.displayMode === 'set' ) {
            // decrement set temperature
            const inc = Number(this.setincrement)
            const min = Number(this.minset)
            this.numSetpoint = floatRound(this.numSetpoint - inc, 1)
            if ( this.numSetpoint >= min ) {
                this.setpoint = this.numSetpoint
                this.doNote(' ', this.$('#lblNote'))
            } else this.doNote(`Already at min setpoint (${min})`, this.$('#lblNote'))

        } else if (this.displayMode === 'mode' ) {
            // change node
            if (this.mode === 'heating') this.mode = 'off'
            else if (this.mode === 'cooling') this.mode = 'heating'
            else this.mode = 'cooling'
            this.checkMode()
        }
    }

    _btnRightClick(evt) {
        if (this.displayMode === 'default' ) {
            // Switch to mode change mode
            this.displayMode = 'mode'
            this.$('#lblMain').childNodes[0].nodeValue = 'MODE'
            this.$('#valMain').childNodes[0].nodeValue = this.modes[this.mode].icon || '⚠️'
            this.$('#lblLeft').childNodes[0].nodeValue = ''
            this.$('#valLeft').childNodes[0].nodeValue = '<'
            this.$('#lblRight').childNodes[0].nodeValue = ''
            this.$('#valRight').childNodes[0].nodeValue = '>'

        } else if (this.displayMode === 'set' ) {
            // increment set temperature
            const inc = Number(this.setincrement)
            const max = Number(this.maxset)
            this.numSetpoint = floatRound(this.numSetpoint + inc, 1)
            if (this.numSetpoint <= max) {
                this.setpoint = this.numSetpoint
                this.doNote(' ', this.$('#lblNote'))
            } else this.doNote(`Already at max setpoint (${max})`, this.$('#lblNote'))

        } else if (this.displayMode === 'mode' ) {
            // change node
            if (this.mode === 'heating') this.mode = 'cooling'
            else if (this.mode === 'cooling') this.mode = 'off'
            else this.mode = 'heating'
            this.checkMode()
        }
    }

    _clickMain(evt) {
        if (this.displayMode !== 'default' ) {
            this.displayMode = 'default'
            this.doNote(' ', this.$('#lblNote'))
            this.$('#lblMain').childNodes[0].nodeValue = 'AMBIENT'
            this.$('#valMain').childNodes[0].nodeValue = this.temperature || '--'
            this.$('#lblLeft').childNodes[0].nodeValue = 'SET'
            this.$('#valLeft').childNodes[0].nodeValue = this.setpoint || '--'
            this.$('#lblRight').childNodes[0].nodeValue = 'MODE'
            this.$('#valRight').childNodes[0].nodeValue = this.modes[this.mode].icon || '⚠️'
        }
    }

    //#endregion ---- ---- ---- ----

    //#region --- Getters/Setters ---

    get temperature() {
        return this.getAttribute('temperature') || '--'
    }

    set temperature(value) {
        this.numTemperature = value === '--' ? 0 : Number(value)
        this.checkMode()
        this.setAttribute('temperature', value || '--')
    }

    get setpoint() {
        return this.getAttribute('setpoint') || '--'
    }

    set setpoint(value) {
        this.numSetpoint = value === '--' ? 0 : Number(value)
        this.checkMode()
        this.setAttribute('setpoint', value || '--')
    }

    get minset() {
        return this.getAttribute('minset') || -999
    }

    set minset(value) {
        this.setAttribute('minset', value || -999)
    }

    get maxset() {
        return this.getAttribute('maxset') || 999
    }

    set maxset(value) {
        this.setAttribute('maxset', value || 999)
    }

    get setincrement() {
        return this.getAttribute('setincrement') || 0.1
    }

    set setincrement(value) {
        this.setAttribute('setincrement', value || 0.1)
    }

    get mode() {
        return this.getAttribute('mode') || 'off'
    }

    set mode(value) {
        this.setAttribute('mode', value || 'off')
    }

    //#endregion --- --- ---

    constructor() {
        super()
        this.attachShadow({ mode: 'open', delegatesFocus: true })
            .append(template.content.cloneNode(true))

        this._data = { ...this.dataset } // All of the data-* attributes as an object
        this._name = this.getAttribute('name')
        this._msg = {
            'topic': this.topic,
            'payload': this.payload ? this.payload : this._data,
            '_meta': {
                id: this.id,
                name: this._name,
                data: this._data, // All of the data-* attributes as an object
            }
        }

        this._clickEvt = new CustomEvent('button-send:click', { 'detail': this._msg })
        this.dispatchEvent(new Event(`${componentName}:construction`, { bubbles: true, composed: true }))

        // Get a reference to the (optional) uibuilder FE client library if possible
        try {
            this.uibuilder = window['uibuilder']
        } catch (e) {
            this.uibuilder = undefined
        }

        // this.addEventListener('click', evt => {
        //     evt.preventDefault()
        //     this._msg._meta = {
        //         id: this.id,
        //         name: this._name,
        //         data: this._data, // All of the data-* attributes as an object
        //         altKey: evt.altKey,
        //         ctrlKey: evt.ctrlKey,
        //         shiftKey: evt.shiftKey,
        //         metaKey: evt.metaKey,
        //     }
        //     document.dispatchEvent(this._clickEvt)
        //     this.uibuilder.send(this._msg)
        // })

    }

    static get observedAttributes() {
        return this.props
    }

    /** NOTE: On initial startup, this is called for each watch attrib set in HTML - BEFORE connectedCallback is called  */
    attributeChangedCallback(attrib, oldVal, newVal) {
        console.log('attributeChangedCallback', attrib, oldVal, newVal)

        // Make sure instance has an ID. Create an id from name or calculation if needed
        // NB: Done here, not in connectedCallback because this fn is called BEFORE that one on first startup
        if (!this.id) {
            if (!this.name) this.name = this.getAttribute('name')
            if (this.name) this.id = this.name.toLowerCase().replace(/\s/g, '_')
            else this.id = `ghostthermometer-${++GhostThermometer._iCount}`
        }

        if ( oldVal === newVal ) return

        switch (attrib) {
            case 'temperature': {
                if (this.displayMode === 'default' ) this.$('#valMain').childNodes[0].nodeValue = newVal || '--'
                break
            }

            case 'setpoint': {
                if ( newVal > this.maxset ) {
                    throw new Error(`Setpoint (${newVal}) must be <= ${this.maxset} (maxset) for <ghost-thermometer id="${this.id}">`)
                } else if ( newVal < this.minset ) {
                    throw new Error(`Setpoint (${newVal}) must be >= ${this.minset} (maxset) for <ghost-thermometer id="${this.id}">`)
                }

                if (this.displayMode === 'default' ) this.$('#valLeft').childNodes[0].nodeValue = newVal || '--'
                else if (this.displayMode === 'set' ) this.$('#valMain').childNodes[0].nodeValue = newVal || '--'
                break
            }

            case 'mode': {
                if ( !Object.keys(this.modes).includes(newVal) ) {
                    this.doNote(`Invalid mode "${newVal}"`, this.$('#lblNote'))
                    this.$('#valRight').childNodes[0].nodeValue = this.$('#valMain').childNodes[0].nodeValue = '⚠️'
                    throw new Error(`Invalid mode (${newVal}). Must be one of "${Object.keys(this.modes).join('", "')}" for <ghost-thermometer id="${this.id}">`)
                }

                if (this.displayMode === 'default' ) this.$('#valRight').childNodes[0].nodeValue = this.modes[newVal].icon || '⚠️'
                else if (this.displayMode === 'mode' ) this.$('#valMain').childNodes[0].nodeValue = this.modes[newVal].icon || '⚠️'
                break
            }

            default: {
                break
            }
        }

        this[attrib] = newVal

    } // --- end of attributeChangedCallback --- //

    // when the component is added to doc. NB: Initial attributeChangedCallbacks happen first
    connectedCallback() {
        // Listen for a uibuilder msg that is targetted at this instance of the component
        document.addEventListener(`uibuilder:msg:_ui:update:${this.id}`, this._uibMsgHandler.bind(this))

        // Add click event handlers for SVG regions
        this.$('#btnLeft').onclick = this._btnLeftClick.bind(this)
        this.$('#btnRight').onclick = this._btnRightClick.bind(this)
        this.$('#clickMain').onclick = this._clickMain.bind(this)
    }

    // Runs when an instance is removed from the DOM
    disconnectedCallback() {
        // NB: Dont decrement GhostThermometer._iCount because that could lead to id nameclashes

        // @ts-ignore
        document.removeEventListener(`uibuilder:msg:_ui:update:${this.id}`, this._uibMsgHandler)
    }

} // ---- End of DefinitionList class definition ---- //

/** Self register the class to global
 * Enables new widgets to be dynamically added via JS
 * and lets the static methods be called
 */
window[className] = GhostThermometer

// Add the class as a new Custom Element to the window object
customElements.define(componentName, GhostThermometer)
1 Like

I also pushed some changes up to the repo. That's where any future changes will go.
TotallyInformation/web-components: A repository of simple W3C Web Components. These have specific capabilities for use with node-red-contrib-uibuilder but will work independently as well. (github.com).

Did a quick test with a uib-element node (just the HTML input) to dynamically create an instance of the thermostat and that works fine. A future version of uib-element will get a generic "tag" mode that will let you create any tag dynamically which would include all standard HTML and web component tags.

Julian just a thought but there were a couple of issues with the OG template, which I don't think were ever fixed. Not sure if they may have copied over to your version ?

see

Hi, thanks for the comment. I actually did the logic from scratch because the original built everything dynamically which is super confusing and totally not needed for a web component version. Also, I've only used uibuilder logic for comms with Node-RED so any issues in comms won't apply here. If anyone wants to use this on Node-RED without uibuilder, they may need to wait for me to add some custom events so that they can process changes in the browser.

My version doesn't quite replicate everything that the original does either as the comms is a lot quieter. I can add more later if anyone wants it.

Overall, I simplified a lot of the logic and the SVG and used more CSS variables to make the SVG more consistent. Also added some additional error checking.

I've not massively tested for rounding errors though I did note that I had to add a proper rounding function. I've made an assumption that numbers will only need 1 DP which should be enough for heating/cooling systems. I also tweaked the main temperature display size so that US users could use temperatures >99 F.

Wow - even my wife would think that's hot :wink:

2 Likes

Sadly though, the US has seen a lot of those temperatures recently I believe.

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.