@flowfuse/node-red-dashboard 1.30.1-829e014-202512291342.0 → 1.30.1-a60a579-202512301349.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -102,6 +102,22 @@ const setters = {
102
102
  }
103
103
  data[node.id].push(config.RED.util.cloneMessage(msg))
104
104
  }
105
+ },
106
+ /**
107
+ * Fast filtering of existing array data (skips cloning and save checks for fast data cleanup)
108
+ * @param {*} base - the base node
109
+ * @param {*} node - the owner node
110
+ * @param {(msg) => Boolean} filterFunction
111
+ */
112
+ filter (base, node, filterFunction) {
113
+ const currentData = data[node.id]
114
+ if (filterFunction && Array.isArray(currentData) && currentData.length) {
115
+ const filteredMessages = currentData.filter(filterFunction)
116
+ if (filteredMessages.length !== currentData.length) {
117
+ // no need for save operation to process messages - just apply them
118
+ data[node.id] = filteredMessages
119
+ }
120
+ }
105
121
  }
106
122
  }
107
123
 
@@ -111,5 +127,6 @@ module.exports = {
111
127
  setConfig: setters.setConfig,
112
128
  save: setters.save,
113
129
  append: setters.append,
130
+ filter: setters.filter,
114
131
  clear: setters.clear
115
132
  }
@@ -106,6 +106,46 @@
106
106
  }
107
107
  }
108
108
  </pre>
109
- The options set earlier will remain in force.
109
+ The options set earlier will remain in force. Generally a message containing `msg.ui_update` should contain
110
+ no payload or other properties otherwise those will be interpreted as data to go on the chart.
111
+ </dd>
112
+ <dd>
113
+ The settings for each series on the chart can also be set using `msg.ui_update`. In particular this allows line
114
+ colours to be configured. Note that when adjusting settings
115
+ for a series you must include all customised settings for all series, as each update to series configuration will
116
+ replace any previous customisation to series.
117
+ For example, for a line chart with two lines 'temperature' and 'humidity', to set the colour of the temperature line
118
+ to red and humidity to green `msg.ui_update` could be sent containing
119
+ <pre>
120
+ {
121
+ "chartOptions": {
122
+ "series": [
123
+ {
124
+ "name": "temperature",
125
+ "type": "line",
126
+ "lineStyle": {
127
+ "color": "red",
128
+ },
129
+ "itemStyle": {
130
+ "color": "red"
131
+ },
132
+ },
133
+ {
134
+ "name": "humidity",
135
+ "type": "line",
136
+ "lineStyle": {
137
+ "color": "rgb(0, 255, 0)"
138
+ },
139
+ "itemStyle": {
140
+ "color": "rgb(0, 255, 0)"
141
+ },
142
+ }
143
+ ]
144
+ }
145
+ }
146
+ </pre>
147
+ Note that this sets the colour for the whole line. It cannot be used to change the colour for just a section of the line.
148
+
149
+ Any such message should always contain the series name and type for each series.
110
150
  </dd>
111
151
  </script>
@@ -14,6 +14,9 @@ module.exports = function (RED) {
14
14
  const group = RED.nodes.getNode(config.group)
15
15
  const base = group.getBase()
16
16
 
17
+ // add a chartOptions object into the config
18
+ config.chartOptions = config.chartOptions || {}
19
+
17
20
  // correct typing
18
21
  if (typeof config.xmin !== 'undefined') {
19
22
  config.xmin = parseFloat(config.xmin)
@@ -58,19 +61,36 @@ module.exports = function (RED) {
58
61
  if (removeOlder > 0) {
59
62
  const removeOlderUnit = parseFloat(config.removeOlderUnit)
60
63
  const ago = (removeOlder * removeOlderUnit) * 1000 // milliseconds ago
61
- const cutoff = (new Date()).getTime() - ago
62
- const _msg = datastore.get(node.id).filter((msg) => {
64
+ const cutOff = (new Date()).getTime() - ago
65
+ const filterFn = (msg) => {
63
66
  let timestamp = msg._datapoint.x
64
67
  // is x already a millisecond timestamp?
65
68
  if (typeof (msg._datapoint.x) === 'string') {
66
69
  timestamp = (new Date(msg._datapoint.x)).getTime()
67
70
  }
68
- return timestamp > cutoff
69
- })
70
- datastore.save(base, node, _msg)
71
+ return timestamp > cutOff
72
+ }
73
+ datastore.filter(base, node, filterFn)
71
74
  }
72
75
  }
73
76
 
77
+ /**
78
+ * For categorical xaxis and types other than histogram then only keep the latest data point for
79
+ * each category in each series
80
+ */
81
+ function clearOldCategoricalPoints () {
82
+ const points = datastore.get(node.id)
83
+ const latestSet = {}
84
+ for (const item of points) {
85
+ const { category, x } = item._datapoint
86
+ const key = JSON.stringify([category, x]) // a unique key for each category/series combination
87
+ latestSet[key] = item
88
+ }
89
+
90
+ const filtered = Object.values(latestSet)
91
+ datastore.save(base, node, filtered)
92
+ }
93
+
74
94
  // ensure sane defaults
75
95
  if (!['msg', 'str', 'property', 'timestamp'].includes(config.xAxisPropertyType)) {
76
96
  config.xAxisPropertyType = 'timestamp' // default to 'timestamp'
@@ -209,61 +229,78 @@ module.exports = function (RED) {
209
229
  if (!datastore.get(node.id)) {
210
230
  datastore.save(base, node, [])
211
231
  }
212
- if (Array.isArray(msg.payload) && !msg.payload.length) {
213
- // clear history
214
- datastore.save(base, node, [])
215
- } else {
216
- // delete old data if a replace is being performed.
217
- // This is the case if msg.action is replace
218
- // or the node is configured for replace and this is not being overriden by msg.action set to append
219
- if (msg.action === 'replace' || (config.action === 'replace' && msg.action !== 'append')) {
220
- // clear our data store as we are replacing data
232
+ // To prevent ui_update messages from deleting old data, skip this section if no msg.payload present
233
+ if (typeof msg.payload !== 'undefined') {
234
+ if (Array.isArray(msg.payload) && !msg.payload.length) {
235
+ // clear history
221
236
  datastore.save(base, node, [])
222
- }
223
- if (!Array.isArray(msg.payload)) {
224
- // quick clone of msg, and store in history
225
- datastore.append(base, node, {
226
- ...msg
227
- })
228
237
  } else {
229
- // we have an array in msg.payload, let's split them
230
- msg.payload.forEach((p, i) => {
231
- const payload = JSON.parse(JSON.stringify(p))
232
- const d = msg._datapoint ? msg._datapoint[i] : null
233
- const m = {
234
- ...msg,
235
- payload,
236
- _datapoint: d
238
+ // delete old data if a replace is being performed.
239
+ // This is the case if msg.action is replace
240
+ // or the node is configured for replace and this is not being overriden by msg.action set to append
241
+ if (msg.action === 'replace' || (config.action === 'replace' && msg.action !== 'append')) {
242
+ // clear our data store as we are replacing data
243
+ datastore.save(base, node, [])
244
+ }
245
+ if (!Array.isArray(msg.payload)) {
246
+ // quick clone of msg, and store in history
247
+ datastore.append(base, node, {
248
+ ...msg
249
+ })
250
+ } else {
251
+ // we have an array in msg.payload, let's split them
252
+ msg.payload.forEach((p, i) => {
253
+ const payload = JSON.parse(JSON.stringify(p))
254
+ const d = msg._datapoint ? msg._datapoint[i] : null
255
+ const m = {
256
+ ...msg,
257
+ payload,
258
+ _datapoint: d
259
+ }
260
+ datastore.append(base, node, m)
261
+ })
262
+ }
263
+
264
+ const maxPoints = parseInt(config.removeOlderPoints)
265
+
266
+ if (maxPoints && config.removeOlderPoints) {
267
+ // account for multiple lines?
268
+ // client-side does this for _each_ line
269
+ // remove older points using datastore.filter instead of saving the whole array
270
+ const lineCounts = {}
271
+ const _msg = datastore.get(node.id) || []
272
+
273
+ // determine which message objects to keep (latest maxPoints per label)
274
+ const keepIndexes = []
275
+ let doFiltering = false
276
+ for (let i = _msg.length - 1; i >= 0; i--) {
277
+ const m = _msg[i]
278
+ const label = m.topic
279
+ lineCounts[label] = lineCounts[label] || 0
280
+ if (lineCounts[label] < maxPoints) {
281
+ keepIndexes[i] = true
282
+ lineCounts[label]++
283
+ } else {
284
+ doFiltering = true
285
+ }
237
286
  }
238
- datastore.append(base, node, m)
239
- })
240
- }
241
287
 
242
- const maxPoints = parseInt(config.removeOlderPoints)
243
-
244
- if (maxPoints && config.removeOlderPoints) {
245
- // account for multiple lines?
246
- // client-side does this for _each_ line
247
- // remove older points
248
- const lineCounts = {}
249
- const _msg = datastore.get(node.id)
250
- // trawl through in reverse order, and only keep the latest points (up to maxPoints) for each label
251
- for (let i = _msg.length - 1; i >= 0; i--) {
252
- const msg = _msg[i]
253
- const label = msg.topic
254
- lineCounts[label] = lineCounts[label] || 0
255
- if (lineCounts[label] >= maxPoints) {
256
- _msg.splice(i, 1)
257
- } else {
258
- lineCounts[label]++
288
+ // filter the datastore to only keep the selected messages
289
+ if (doFiltering) {
290
+ datastore.filter(base, node, (m, i) => {
291
+ return keepIndexes[i]
292
+ })
259
293
  }
260
294
  }
261
- datastore.save(base, node, _msg)
262
- }
263
295
 
264
- if (config.xAxisType === 'time' && config.removeOlder && config.removeOlderUnit) {
265
- // remove any points older than the specified time
266
- clearOldPoints()
296
+ if (config.xAxisType === 'time' && config.removeOlder && config.removeOlderUnit) {
297
+ // remove any points older than the specified time
298
+ clearOldPoints()
299
+ } else if (config.xAxisType === 'category' && config.chartType !== 'histogram') {
300
+ // for categorical xaxis and types other than histogram then only keep the latest data point for
301
+ // each category in each series
302
+ clearOldCategoricalPoints()
303
+ }
267
304
  }
268
305
  }
269
306
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowfuse/node-red-dashboard",
3
- "version": "1.30.1-829e014-202512291342.0",
3
+ "version": "1.30.1-a60a579-202512301349.0",
4
4
  "description": "FlowFuse Dashboard - A collection of Node-RED nodes that provide functionality to build your own UI applications (inc. forms, buttons, charts).",
5
5
  "keywords": [
6
6
  "node-red"