Class for animating SVG graphics (Use anywhere but specially with UIBUILDER)

Hi all, with renewed interest in weather API's recently, I thought I should finally get round to reworking something shared by @Soiski back in early 2023.

That showed how to animate SVG images in Dashboard 1. I've now adapted that for use with UIBUILDER.

Below is the example. Note that the example is an HMTL Fragment. It is formatted for use in UIBUILDER's front-end SPA router. However, it is easily adapted for single-page use.

The example contains an SVG wind compass with direction and wind-speed.

However, this is adaptable to any SVG if you follow the structure and data schema listed here: GitHub - alex-controlx/red-dashboard-svg-control: Controllable SVG elements in Node-RED Dashboard via UI Template node.

Here are 2 function nodes that will output the correct data:

[{"id":"cce0111a92e818a2","type":"function","z":"93d9566b0ed88757","name":"Wind D","func":"return {\n    payload: {\n        x: 0,\n        y: 0,\n        // deg: msg.payload,\n        deg: 240,\n        pivot: [0.5, 0.53859]\n    },\n    topic: \"weather/compass@cx_move\",\n}\n","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":100,"y":40,"wires":[[]]},{"id":"d4518c7c791ac63d","type":"function","z":"93d9566b0ed88757","name":"WS to String","func":"return {\n    topic: 'weather/WS@cx_status',\n    // payload: String(msg.payload),\n    payload: '32',\n\n}\n","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":90,"y":80,"wires":[[]]}]

And here is the code:

<article>
    <h2>Weather AI</h2>
    <p>Opens a new page where you can ask about the weather.</p>
    <p>
        <a id="weatherai" href="" title="Waiting for API key">Go to Weather AI</a>
    </p>
</article>
<article>
    <h2>Weather Station</h2>
    <p>This page shows the current weather conditions.</p>
    <div id="weather-rose">
        <svg viewBox="0 0 400 400" width="275px" height="275px" xmlns="http://www.w3.org/2000/svg"
            xmlns:bx="https://boxy-svg.com">
            <defs></defs>
            <g id="compass@cx_move">
                <g transform="matrix(1, 0, 0, 1, 0.000006, 0)" bx:origin="0.5 0.535722">
                    <path
                        d="M 351.501 200 C 351.501 283.672 283.672 351.501 200 351.501 C 116.328 351.501 48.499 283.672 48.499 200 C 48.499 116.328 116.328 48.499 200 48.499 C 283.672 48.499 351.501 116.328 351.501 200 Z M 200 70.355 C 126.79 70.355 67.441 128.399 67.441 200 C 67.441 271.601 126.79 329.645 200 329.645 C 273.21 329.645 332.559 271.601 332.559 200 C 332.559 128.399 273.21 70.355 200 70.355 Z"
                        style="fill: rgb(255, 255, 255); stroke: rgb(255, 255, 255);"></path>
                    <path d="M 199.737 25.186 L 218.363 82.012 L 181.112 82.012 L 199.737 25.186 Z"
                        style="fill: rgb(255, 0, 0); stroke: rgb(255, 255, 255);"
                        bx:shape="triangle 181.112 25.186 37.251 56.826 0.5 0 1@f0735005"></path>
                </g>
            </g>
            <text id="WS@cx_status"
                style="fill: rgb(255, 255, 255); font-family: &quot;Agency FB&quot;; font-size: 20px; font-weight: 700; text-anchor: middle; white-space: pre;"
                transform="matrix(6.652061, 0, 0, 5.619027, -893.371887, -830.482849)" x="164.307" y="190.806"
            >
                00
            </text>
        </svg>
    </div>
</article>

<script>
    /** SVGAnimate - A class for animating SVG elements
     * Provides methods to animate SVG elements based on their ID format: <id>@<animation_type>
     *
     * SVGAnimatableObject - Interface representing an animatable SVG object
     * @typedef {object} SVGAnimatableObject
     * @property {string} id - The ID of the element
     * @property {SVGElement} element - Reference to the SVG element
     * @property {boolean} cxMove - Whether the element supports movement animation
     * @property {boolean} cxColor - Whether the element supports color animation
     * @property {boolean} cxStatus - Whether the element supports status text changes
     * @property {boolean} cxHide - Whether the element supports visibility toggling
     * @property {boolean} cxStroke - Whether the element supports stroke modifications
     *
     * TransformConfig - Configuration for SVG transformations
     * @typedef {object} TransformConfig
     * @property {number} [x=0] - X translation
     * @property {number} [y=0] - Y translation
     * @property {number} [deg=0] - Rotation in degrees
     * @property {[number, number]} [pivot=[0.5, 0]] - Pivot point for rotation [x, y]
     */
    class SVGAnimate {
        /** @type {string} Animation type identifier for movement */
        cxMove = 'cx_move'
        /** @type {string} Animation type identifier for color changes */
        cxColor = 'cx_color'
        /** @type {string} Animation type identifier for status text */
        cxStatus = 'cx_status'
        /** @type {string} Animation type identifier for visibility toggling */
        cxHide = 'cx_hide'
        /** @type {string} Animation type identifier for stroke modifications */
        cxStroke = 'cx_stroke'
        /** @type {number} Maximum length for status text before truncation */
        maxStatusLength = 12
        /** @type {Array<SVGAnimatableObject>|null} Collection of discovered animatable objects */
        graphicObjects = null

        /**
         * Creates a new SVGAnimate instance
         * @param {string} elementId - The ID of the SVG element to animate
         */
        constructor(elementId) {
            this.graphicObjects = []

            if (!this.graphicObjects) {
                this.graphicObjects = []
            }
            const svgElement = document.getElementById(elementId)
            if (!svgElement) {
                console.error(`SVG element with ID '${elementId}' not found`)
                return
            }

            this.recursiveEach(svgElement, this.graphicObjects)
            console.log(`Checking SVG "${elementId}" for animatable graphic objects ... ${this.graphicObjects.length} found`, this.graphicObjects)
        }

        /** Recursively traverse the SVG elements and collect graphic objects
         * @param {SVGElement} element - The current SVG element
         * @param {Array<SVGAnimatableObject>} container - The array to collect graphic objects
         */
        recursiveEach(element, container) {
            [...element.children].forEach((child) => {
                if (child.id && child.id.indexOf('@') > 0) {
                    container.push({
                        id: child.id,
                        element: child,
                        cxMove: (child.id.indexOf(this.cxMove) > 0),
                        cxColor: (child.id.indexOf(this.cxColor) > 0),
                        cxStatus: (child.id.indexOf(this.cxStatus) > 0),
                        cxHide: (child.id.indexOf(this.cxHide) > 0),
                        cxStroke: (child.id.indexOf(this.cxStroke) > 0),
                    })
                }
                this.recursiveEach(child, container)
            })
        }

        /** Animate a graphic object based on the payload and topic
         *
         * @param {any} payload - The data to animate the object with
         * @param {string} topic - The topic associated with the object (format: <id>@<type>)
         * @returns {void}
         *
         * @example
         * // Rotate element with ID 'compass@cx_move'
         * svgAnimate.animateObject({deg: 45, pivot: [0.5, 0.5]}, 'compass@cx_move')
         *
         * // Update text in element with ID 'WS@cx_status'
         * svgAnimate.animateObject('25', 'WS@cx_status')
         */
        animateObject(payload, topic) {
            if (payload === undefined || payload === null || !topic || typeof topic !== 'string' || !topic.includes('@')) {
                return
            }

            const id = topic.split('@')[0]
            const cxType = topic.split('@')[1]

            for (const graphicObject of this.graphicObjects) {
                const objId = graphicObject.id.split('@')[0]
                if (objId !== id || graphicObject.id.indexOf(cxType) === -1) continue

                if (cxType === this.cxColor && graphicObject.cxColor && typeof payload === 'string') {
                    graphicObject.element.style.fill = payload
                } else if (cxType === this.cxStatus && graphicObject.cxStatus && typeof payload === 'string') {
                    const statusText = (payload.length > this.maxStatusLength)
                        ? payload.substring(0, this.maxStatusLength - 3) + '...'
                        : payload
                    graphicObject.element.textContent = statusText
                } else if (cxType === this.cxHide && graphicObject.cxHide && typeof payload === 'boolean') {
                    const displayAttr = (payload) ? 'none' : ''
                    graphicObject.element.style.display = displayAttr
                } else if (cxType === this.cxMove && graphicObject.cxMove) {
                    const box = graphicObject.element.getBBox()
                    const matrix = this.getMatrix(payload, box)
                    if (matrix) graphicObject.element.setAttribute('transform', matrix)
                } else if (cxType === this.cxStroke && graphicObject.cxStroke) {
                    if (payload.color != null) graphicObject.element.style.stroke = payload.color
                    if (payload.width != null) graphicObject.element.style.strokeWidth = payload.width
                }
            }
        }

        /** Get the transformation matrix for an SVG element
         *
         * @param {TransformConfig} config - The configuration object for the transformation
         * @param {DOMRect} box - The bounding box of the SVG element
         * @returns {string|null} The transformation matrix as a string
         */
        getMatrix(config, box) {
            if (!config || !box) return null

            // translation
            const tx = config.x || 0
            const ty = config.y || 0
            // angle in radians
            const rads = config.deg * (Math.PI / 180) || 0
            // rotation centre
            const pivot_x = (config.pivot && config.pivot[0]) ? config.pivot[0] : 0
            const pivot_y = (config.pivot && config.pivot[1]) ? config.pivot[1] : 0
            const cx = box.x + box.width * ((pivot_x) ? pivot_x : 0.5)
            const cy = box.y + box.height * ((pivot_y) ? pivot_y : 0)
            // scaling
            const sx = 1
            const sy = 1

            return `matrix(
                ${sx * Math.cos(rads)}, ${sy * Math.sin(rads)}, 
                ${-sx * Math.sin(rads)}, ${sy * Math.cos(rads)}, 
                ${(-Math.cos(rads) * cx + Math.sin(rads) * cy + cx) * sx + tx}, 
                ${(-Math.sin(rads) * cx - Math.cos(rads) * cy + cy) * sy + ty}
            )`
        }
    }

    if (!window.weatherSetup) { // Only run once each page load
        window.weatherSetup = {
            doWeather: (msg) => {
                console.log('doWeather', msg)

                if (msg.payload && msg.apikey) {
                    const weatherai = document.getElementById('weatherai')
                    weatherai.href = `https://openweathermap.org/weather-assistant?apikey=${msg.apikey}`
                    weatherai.title = 'Go to Weather AI'
                }

                // Animate the wind compass rose
                window.svgAnimate.animateObject({
                    x: 0,
                    y: 0,
                    deg: msg.payload.current.wind_deg,
                    pivot: [0.5, 0.53859],
                }, 'compass@cx_move')
                window.svgAnimate.animateObject(String(msg.payload.current.wind_speed), 'WS@cx_status')
            },
        }

        // Listen for weather updates from Node-RED
        uibuilder.onTopic('weather', window.weatherSetup.doWeather)

        // Initialize the SVG animation object - based on https://discourse.nodered.org/t/live-data-on-top-of-changing-picture/76128
        window.svgAnimate = new SVGAnimate('weather-rose')
    }
</script>
1 Like

I've just updated the code so that the SVG Animation code is stand-alone and more easily transportable to other pages. svgAnimate is the object. This version still only works with 1 SVG on a page. I need to turn that object into a class so it can be reused with multiple SVG's on a page.

And, no, I just can't leave it alone!! Another updated version using a proper class so you can have multiple animatable SVG's on the page.

I'm thinking this is likely to end up in a future version of UIBUILDER :smiley: