Source: led-gauge.js

/** Define a new zero dependency custom web component ECMA module that can be used as an HTML tag
 *
 * TO USE THIS TEMPLATE: CHANGE ALL INSTANCES OF 'ComponentTemplate' and 'led-gauge'
 * For better formatting of HTML in template strings, use VSCode's "ES6 String HTML" extension
 *
 * Version: See the class code
 *
 */
/** Copyright (c) 2022-2025 Julian Knight (Totally Information)
 * https://it.knightnet.org.uk, https://github.com/TotallyInformation
 *
 * 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.
 */

import TiBaseComponent from '../libs/ti-base-component'

/** Only use a template if you want to isolate the code and CSS */
const template = document.createElement('template')
template.innerHTML = /*html*/`
    <style>
        :host {
            --value-color: var(--text2, inherit);
            --gauge-background-color: var(--surface2, inherit);
            --hue: 120; /* 0 = red, 120 = green, 240 = blue */
            --on-sat: 100%;
            --off-sat: 20%;
            --on-lum: 45%;
            --off-lum: 25%;
            --label-color: var(--value-color, inherit);
            
            --segment-count: 10;
            --segment-gap: 0.3rem;
            --gauge-columns: 1fr 1fr;
            --gauge-layout: 
                "label value"
                "segments segments"
                "segvals segvals";
            --value-justification: end;

            contain: content; /* performance boost */
            display: grid;
            width: 100%;
            grid-template-columns: var(--gauge-columns);
            grid-template-areas: var(--gauge-layout);
            padding: 1rem;
            background-color: var(--gauge-background-color);
            color: var(--value-color);
            border-radius: var(--border-radius, 5px);
        }

        
        .segments {
            grid-area: segments;
            display: grid;
            grid-template-columns: repeat(var(--segment-count, 10), 1fr);
            gap: var(--segment-gap);
        }

        .segvals {
            grid-area: segvals;
            display: grid;
            grid-template-columns: 0fr repeat(var(--segment-count, 10), 1fr) 0fr;
            justify-items: start;
            /* margin-left: -.5rem; */
            /* gap: var(--segment-gap); */
        }

        .led {
            background-color: hsl(var(--hue, 0), var(--off-sat, 20%), var(--off-lum, 25%));
            height: 20px;
            border-radius: 3px;
            border: 1px solid var(--gauge-background-color);
            cursor: pointer;
        }

        .led.on {
            background-color: hsl(var(--hue, 0), var(--on-sat, 100%), var(--on-lum, 45%));
            /* box-shadow: 0 0 5px hsl(var(--hue, 0), var(--on-sat, 100%), var(--on-lum, 45%)); */
        }

        slot {
            grid-area: label;
            color: var(--label-color, inherit);
        }
        output {
            grid-area: value;
            justify-self: var(--value-justification, end);
            color: var(--value-color, inherit);
        }
    </style>

    <slot></slot>
    <output class="value"></output>
    <div class="segments" arial-label="Visual LED Gauge"></div>
    <div class="segvals"></div>
`

/** Namespace
 * @namespace Alpha
 */

/**
 * @class
 * @augments TiBaseComponent
 * @description Define a new zero dependency custom web component ECMA module that can be used as an HTML tag
 *
 * @element led-gauge
 * @memberOf Alpha

 * METHODS FROM BASE: (see TiBaseComponent)
 * STANDARD METHODS:
  * @function attributeChangedCallback Called when an attribute is added, removed, updated or replaced
  * @function connectedCallback Called when the element is added to a document
  * @function constructor Construct the component
  * @function disconnectedCallback Called when the element is removed from a document

 * OTHER METHODS:
  * @function valueChanged Process value changed event
  * @function _renderGauge (Re)Create the gauge

 * CUSTOM EVENTS:
  * "led-gauge:connected" - When an instance of the component is attached to the DOM. `evt.details` contains the details of the element.
  * "led-gauge:ready" - Alias for connected. The instance can handle property & attribute changes
  * "led-gauge:disconnected" - When an instance of the component is removed from the DOM. `evt.details` contains the details of the element.
  * "led-gauge:attribChanged" - When a watched attribute changes. `evt.details.data` contains the details of the change.
  *
  * "led-gauge:value-changed" - When the value changes. `evt.details.data` contains the new value.
  * "led-gauge:segment-click" - When a segment is clicked. `evt.details.data` contains the details of the segment & current value.
  * NOTE that listeners can be attached either to the `document` or to the specific element instance.

 * Standard watched attributes (common across all my components):
  * @property {string|boolean} inherit-style - Optional. Load external styles into component (only useful if using template). If present but empty, will default to './index.css'. Optionally give a URL to load.
  * @property {string} name - Optional. HTML name attribute. Included in output _meta prop.

 * Other watched attributes:
  * @property {string|number} value (getter/setter) The current value of the gauge. #value is the private equivalent property
  * @property {string|number} max The maximum value of the gauge
  * @property {string|number} min The minimum value of the gauge
  * @property {string|number} segments (getter/setter) The number of segments in the gauge. #segments is the private equivalent property
  * @property {string} unit The unit of the gauge value
  * @property {string} hide-segment-labels

 * PROPS FROM BASE: (see TiBaseComponent)
 * OTHER STANDARD PROPS:
  * @property {string} componentVersion Static. The component version string (date updated). Also has a getter that returns component and base version strings.

 * Other props:
  * @property {string[]} colors The color of each segment in the gauge
  * @property {boolean} hideSegmentLabels If true, hide the segment labels (hide-segment-labels attribute)
  * @property {HTMLElement} segContainerEl The container for the gauge segments
  * @property {HTMLCollection} segmentElements A collection of the segment div elements
  * @property {HTMLElement} valsContainerEl The container for the segment values
  * @property {HTMLElement} valueEl The container for the gauge value
  * By default, all attributes are also created as properties

 * @slot Container contents

 * See https://github.com/runem/web-component-analyzer?tab=readme-ov-file#-how-to-document-your-components-using-jsdoc
 */
class LedGauge extends TiBaseComponent {
    /** Component version */
    static componentVersion = '2025-02-25'

    #value = 0
    #segments = 10
    #colors = {}
    // #colors = {
    //     60: 40, // 60%+ is orange
    //     80: 0, // 80%+ is red
    // }
    #min = 0
    #max = 100
    #unit = '%'
    #hideSegmentLabels = false

    /** @type {HTMLElement} */
    segContainerEl
    /** @type {HTMLElement} */
    valsContainerEl
    /** @type {HTMLElement} */
    valueEl
    /** @type {HTMLCollection} */
    segmentElements

    /** Makes HTML attribute change watched
     * @returns {Array<string>} List of all of the html attribs (props) listened to
     */
    static get observedAttributes() {
        return [
            // Standard watched attributes:
            'inherit-style', 'name',
            // Other watched attributes:
            'value', 'min', 'max', 'unit',
            'segments', 'hide-segment-labels',
        ]
    }

    /** Set the number of segments
     * @param {string|number} val Number of segments
     */
    set segments(val) {
        const strSegments = val.toString()
        // Ensure that saved #segments value is numeric
        this.#segments = parseInt(strSegments)
        // Update the CSS variable for the number of segments
        this.style.setProperty('--segment-count', strSegments)
        // (Re)Create and show the gauge
        this._renderGauge()
    }

    /** Get the number of segments
     * @returns {number} Number of segments
     */
    get segments() {
        return this.#segments
    }

    /** Set the value of the gauge
     * @param {string|number} val The value to set
     */
    set value(val) {
        const oldVal = this.#value
        // Ensure that saved #segments value is numeric
        this.#value = parseFloat(val.toString())
        // (Re)Create and show the gauge
        this._renderGauge()
        // Only notify once the component is fully connected
        if (this.connected) {
            // Notify listeners of the change
            this._event('value-change', {
                value: this.#value,
                oldValue: oldVal,
            })
            // If using uibuilder, send the new value to Node-RED.
            this.uibSend('value-change', {
                value: this.#value,
                oldValue: oldVal,
            })
        }
    }

    /** Get the value of the gauge
     * @returns {number} The current value
     */
    get value() {
        return this.#value
    }

    /** Set the value of the gauge
     * @param {object} val The value to set
     */
    set colors(val) {
        this.#colors = val
        // (Re)Create and show the gauge
        this._renderGauge()
    }

    /** Get the value of the gauge
     * @returns {object} The current value
     */
    get colors() {
        return this.#colors
    }

    /** Set the minimum value of the gauge
     * @param {string|number} val The minimum value
     */
    set min(val) {
        // Ensure that the internal value is numeric
        this.#min = parseFloat(val.toString())
        // (Re)Create and show the gauge
        this._renderGauge()
    }

    /** Get the minimum value of the gauge
     * @returns {number} The minimum value
     */
    get min() {
        return this.#min
    }

    /** Set the maximum value of the gauge
     * @param {string|number} val The maximum value
     */
    set max(val) {
        this.#max = parseFloat(val.toString())
        // (Re)Create and show the gauge
        this._renderGauge()
    }

    /** Get the maximum value of the gauge
     * @returns {number} The maximum value
     */
    get max() {
        return this.#max
    }

    /** Set the unit of the gauge
     * @param {string} val The unit. Default '%'
     * @example '°C'
     */
    set unit(val) {
        this.#unit = val
        this.valueEl.innerText = `${this.value}${this.#unit}`
    }

    /** Get the unit of the gauge
     * @returns {string} The unit
     */
    get unit() {
        return this.#unit
    }

    /** Set whether to hide the segment labels
     * @param {string|boolean} val Whether to hide the segment labels. Default false
     */
    set hideSegmentLabels(val) {
        if (val === '' || val === null || val.toString().toLowerCase() === 'true') val = true
        else val = false
        this.#hideSegmentLabels = val
        this.valsContainerEl.style.display = val ? 'none' : 'grid'
    }

    /** Get whether segment labels are hidden
     * @returns {boolean} Whether segment labels are hidden
     */
    get hideSegmentLabels() {
        return this.#hideSegmentLabels
    }

    /** NB: Attributes not available here - use connectedCallback to reference */
    constructor() {
        super()
        // Only attach the shadow dom if code and style isolation is needed - comment out if shadow dom not required
        if (template && template.content) this._construct(template.content.cloneNode(true))

        // Keep a reference to the segments container
        this.segContainerEl = this.shadowRoot.querySelector('.segments')
        // Keep a reference to the segment values container
        this.valsContainerEl = this.shadowRoot.querySelector('.segvals')
        // Keep a reference to the value container
        this.valueEl = this.shadowRoot.querySelector('.value')
    }

    /** Runs when an instance is added to the DOM */
    connectedCallback() {
        this._connect() // Keep at start.

        // Set up the gauge
        this._renderGauge()

        this._ready() // Keep at end. Let everyone know that a new instance of the component has been connected & is ready
    }

    /** Runs when an instance is removed from the DOM */
    disconnectedCallback() {
        this._disconnect() // Keep at end.
    }

    /** Runs when an observed attribute changes - Note: values are always strings
     * @param {string} attrib Name of watched attribute that has changed
     * @param {string} oldVal The previous attribute value
     * @param {string} newVal The new attribute value
     */
    attributeChangedCallback(attrib, oldVal, newVal) {
        /** Optionally ignore attrib changes until instance is fully connected
         * Otherwise this can fire BEFORE everthing is fully connected.
         */
        // if (!this.connected) return

        if (attrib === 'hide-segment-labels') attrib = 'hideSegmentLabels'

        // Don't bother if the new value same as old
        if ( oldVal === newVal ) return
        // Create a property from the value - WARN: Be careful with name clashes
        this[attrib] = newVal

        // NOTE: value and segments are handled by their own setters.

        // Add other dynamic attribute processing here.
        // If attribute processing doesn't need to be dynamic, process in connectedCallback as that happens earlier in the lifecycle

        // Keep at end. Let everyone know that an attribute has changed for this instance of the component
        this._event('attribChanged', { attribute: attrib, newVal: newVal, oldVal: oldVal, })
    }

    /** Create the gauge */
    _renderGauge() {
        // Calculate the segment step size
        const step = (this.#max - this.#min) / this.#segments

        // Clear out the existing segments
        this.segContainerEl.innerHTML = ''
        this.valsContainerEl.innerHTML = ''

        // Create LED segments
        for (let i = 0; i < this.#segments; i++) {
            // Create the segments
            const segment = document.createElement('div')
            segment.classList.add('led')
            const segmentValue = this.#min + i * step
            segment.title = segmentValue.toString()

            // Create the segment values
            if (i === 0) {
                const segVal = document.createElement('div')
                this.valsContainerEl.appendChild(segVal)
            }
            const segVal = document.createElement('div')
            segVal.innerText = Math.round(segmentValue).toString()

            // if colorSegments has a key greater than or equal to the segVal, set the hue to the value of the key
            const hueKey = Object.keys(this.#colors).reverse().find(key => { // eslint-disable-line @stylistic/newline-per-chained-call
                return Number(key) <= segmentValue + step - 0.01
            })
            if (hueKey !== undefined) segment.style.setProperty('--hue', this.#colors[hueKey])

            // Is the segment on?
            if (this.#value >= segmentValue) segment.classList.add('on')

            // Attach click event listener
            segment.addEventListener('click', () => {
                const data = {
                    gaugeValue: this.#value,
                    segment: i,
                    segmentValue: Number(segmentValue),
                }
                // Fire custom event
                this._event('segment-click', data)
                // If using uibuilder, send the new value to Node-RED.
                this.uibSend('segment-click', data)
            })

            this.segContainerEl.appendChild(segment)
            this.valsContainerEl.appendChild(segVal)
        }

        // Keep a reference to the segment div elements
        this.segmentElements = this.segContainerEl.getElementsByTagName('div')

        const segVal = document.createElement('div')
        this.valsContainerEl.appendChild(segVal)
        segVal.innerText = this.#max.toString()

        // Render current value and unit if applicable
        this.valueEl.innerText = `${this.value}${this.#unit}`
    }
} // ---- end of Class ---- //

// Make the class the default export so it can be used elsewhere
export default LedGauge

/** Self register the class to global
 * Enables new data lists to be dynamically added via JS
 * and lets the static methods be called
 */
window['LedGauge'] = LedGauge

// Self-register the HTML tag
customElements.define('led-gauge', LedGauge)