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: "Agency FB"; 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>