node-red-contrib-event-calc 0.1.2 → 2.0.1

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.
@@ -13,10 +13,12 @@
13
13
  inputMappings: { value: [] },
14
14
  expression: { value: "" },
15
15
  triggerOn: { value: "any" },
16
- outputTopic: { value: "calc/result" }
16
+ outputTopic: { value: "calc/result" },
17
+ externalTrigger: { value: false }
17
18
  },
18
19
  inputs: 1,
19
- outputs: 1,
20
+ outputs: 2,
21
+ outputLabels: ["result", "error"],
20
22
  icon: "font-awesome/fa-calculator",
21
23
  label: function() {
22
24
  return this.name || this.expression || "event calc";
@@ -34,7 +36,7 @@
34
36
  $.getJSON("event-cache/" + cacheId + "/topics", function(topics) {
35
37
  cachedTopics = topics || [];
36
38
  // Update existing autocomplete instances
37
- $(".input-pattern").each(function() {
39
+ $(".input-topic").each(function() {
38
40
  $(this).autocomplete("option", "source", cachedTopics);
39
41
  });
40
42
  });
@@ -92,17 +94,17 @@
92
94
 
93
95
  $('<span/>', { style: "flex: 0 0 auto;" }).text(" = ").appendTo(row);
94
96
 
95
- const patternInput = $('<input/>', {
97
+ const topicInput = $('<input/>', {
96
98
  type: "text",
97
99
  placeholder: "topic (type to search cached topics)",
98
- class: "input-pattern"
100
+ class: "input-topic"
99
101
  })
100
102
  .css({ flex: "1 1 auto" })
101
- .val(data.pattern || "")
103
+ .val(data.topic || data.pattern || "")
102
104
  .appendTo(row);
103
105
 
104
- // Add autocomplete to pattern input
105
- patternInput.autocomplete({
106
+ // Add autocomplete to topic input
107
+ topicInput.autocomplete({
106
108
  source: cachedTopics,
107
109
  minLength: 0,
108
110
  delay: 0
@@ -135,9 +137,9 @@
135
137
 
136
138
  $("#node-input-inputMappings-list").editableList('items').each(function() {
137
139
  const name = $(this).find(".input-name").val().trim();
138
- const pattern = $(this).find(".input-pattern").val().trim();
139
- if (name && pattern) {
140
- node.inputMappings.push({ name: name, pattern: pattern });
140
+ const topic = $(this).find(".input-topic").val().trim();
141
+ if (name && topic) {
142
+ node.inputMappings.push({ name: name, topic: topic });
141
143
  }
142
144
  });
143
145
  }
@@ -157,7 +159,7 @@
157
159
  <label style="width:100%;"><i class="fa fa-sign-in"></i> Input Variables</label>
158
160
  <ol id="node-input-inputMappings-list"></ol>
159
161
  <div class="form-tips">
160
- Map variable names to topic patterns. Wildcards: <code>?</code> = one char, <code>*</code> = one or more chars
162
+ Map variable names to topics.
161
163
  </div>
162
164
  </div>
163
165
  <div class="form-row">
@@ -212,6 +214,11 @@
212
214
  <label for="node-input-outputTopic"><i class="fa fa-bookmark"></i> Output Topic</label>
213
215
  <input type="text" id="node-input-outputTopic" placeholder="calc/result">
214
216
  </div>
217
+ <div class="form-row">
218
+ <label>&nbsp;</label>
219
+ <input type="checkbox" id="node-input-externalTrigger" style="width:auto; margin-right:5px;">
220
+ <label for="node-input-externalTrigger" style="width:auto;"> External Trigger - calculate on any input message</label>
221
+ </div>
215
222
  </script>
216
223
 
217
224
  <script type="text/html" data-help-name="event-calc">
@@ -220,7 +227,7 @@
220
227
  <h3>Properties</h3>
221
228
  <dl class="message-properties">
222
229
  <dt>Input Variables</dt>
223
- <dd>Map variable names to topic patterns. Each variable receives the latest value from matching topics.</dd>
230
+ <dd>Map variable names to topics.</dd>
224
231
  <dt>Expression</dt>
225
232
  <dd>JavaScript expression using the variable names, e.g. <code>a + b</code>, <code>Math.max(a, b)</code>, <code>(a - b) / a * 100</code></dd>
226
233
  <dt>Trigger</dt>
@@ -231,7 +238,9 @@
231
238
  </ul>
232
239
  </dd>
233
240
  <dt>Output Topic</dt>
234
- <dd>Topic to set on output messages</dd>
241
+ <dd>Topic for output messages.</dd>
242
+ <dt>External Trigger</dt>
243
+ <dd>When enabled, any incoming message will trigger a calculation using the current cached values. Useful for time-based or event-driven calculations.</dd>
235
244
  </dl>
236
245
 
237
246
  <h3>Inputs</h3>
@@ -243,19 +252,40 @@
243
252
  </dl>
244
253
 
245
254
  <h3>Outputs</h3>
255
+ <p>The node has two outputs:</p>
256
+ <ol>
257
+ <li><b>Result</b> - Successful calculation results</li>
258
+ <li><b>Error</b> - Errors (NaN results, evaluation failures)</li>
259
+ </ol>
260
+
261
+ <h4>Output 1 (Result)</h4>
246
262
  <dl class="message-properties">
247
263
  <dt>payload <span class="property-type">any</span></dt>
248
264
  <dd>The result of the expression</dd>
249
265
  <dt>topic <span class="property-type">string</span></dt>
250
- <dd>The configured output topic</dd>
266
+ <dd>The output topic</dd>
267
+ <dt>topics <span class="property-type">object</span></dt>
268
+ <dd>Mapping of variable names to topics</dd>
269
+ <dt>timestamps <span class="property-type">object</span></dt>
270
+ <dd>Mapping of variable names to their cached timestamps</dd>
251
271
  <dt>inputs <span class="property-type">object</span></dt>
252
- <dd>Details of all input values used in the calculation</dd>
272
+ <dd>Full details of all input values</dd>
253
273
  <dt>expression <span class="property-type">string</span></dt>
254
274
  <dd>The expression that was evaluated</dd>
255
275
  <dt>trigger <span class="property-type">string</span></dt>
256
276
  <dd>The topic that triggered this calculation</dd>
257
277
  </dl>
258
278
 
279
+ <h4>Output 2 (Error)</h4>
280
+ <dl class="message-properties">
281
+ <dt>payload.error <span class="property-type">string</span></dt>
282
+ <dd>Error message (e.g., "Expression resulted in NaN")</dd>
283
+ <dt>payload.missingInputs <span class="property-type">array</span></dt>
284
+ <dd>List of input variables that were undefined</dd>
285
+ <dt>payload.expression <span class="property-type">string</span></dt>
286
+ <dd>The expression that failed</dd>
287
+ </dl>
288
+
259
289
  <h3>Built-in Functions</h3>
260
290
  <p>The following functions are available in expressions:</p>
261
291
 
@@ -304,10 +334,4 @@
304
334
  <li><code>ifelse(a > b, 'high', 'low')</code> - Conditional</li>
305
335
  <li><code>pctChange(a, b)</code> - Percent change from b to a</li>
306
336
  </ul>
307
-
308
- <h3>Wildcard Examples</h3>
309
- <ul>
310
- <li><code>sensor?</code> - matches sensor1, sensorA (exactly one char)</li>
311
- <li><code>sensors/*</code> - matches sensors/temp, sensors/room1/temp</li>
312
- </ul>
313
337
  </script>
@@ -2,7 +2,7 @@
2
2
  * event-calc - Calculation node for multi-topic expressions
3
3
  *
4
4
  * Features:
5
- * - Maps multiple variables to topic patterns
5
+ * - Maps variables to exact topics
6
6
  * - Evaluates JavaScript expressions when inputs update
7
7
  * - Trigger modes: 'any' (any input updates) or 'all' (all inputs have values)
8
8
  * - Safe expression evaluation using Function constructor
@@ -10,6 +10,7 @@
10
10
  * - Built-in helper functions for common operations
11
11
  */
12
12
  module.exports = function(RED) {
13
+
13
14
  // Helper functions available in expressions
14
15
  const helpers = {
15
16
  // Math shortcuts
@@ -65,9 +66,9 @@ module.exports = function(RED) {
65
66
  node.expression = config.expression || '';
66
67
  node.triggerOn = config.triggerOn || 'any';
67
68
  node.outputTopic = config.outputTopic || 'calc/result';
69
+ node.externalTrigger = config.externalTrigger || false;
68
70
 
69
71
  const subscriptionIds = [];
70
- const latestValues = new Map(); // name -> { topic, value, ts }
71
72
 
72
73
  if (!node.cacheConfig) {
73
74
  node.status({ fill: "red", shape: "ring", text: "no cache configured" });
@@ -84,32 +85,43 @@ module.exports = function(RED) {
84
85
  return;
85
86
  }
86
87
 
88
+ // Track subscribed topics to ignore updates from our own output
89
+ const subscribedTopics = new Set();
90
+ for (const input of node.inputMappings) {
91
+ const topicName = input.topic || input.pattern;
92
+ if (topicName) {
93
+ subscribedTopics.add(topicName);
94
+ }
95
+ }
96
+
87
97
  /**
88
98
  * Attempt to calculate and output result
89
99
  */
90
- function tryCalculate(triggerTopic) {
91
- // Check if we should trigger
100
+ function tryCalculate(triggerTopic, latestValues) {
101
+ // Ignore updates triggered by our own output
102
+ if (triggerTopic === node.outputTopic) {
103
+ return;
104
+ }
105
+
92
106
  if (node.triggerOn === 'all') {
93
- // All inputs must have values
94
107
  for (const input of node.inputMappings) {
95
108
  if (!latestValues.has(input.name)) {
96
- return; // Not all values available yet
109
+ return;
97
110
  }
98
111
  }
99
112
  }
100
113
 
101
- // At least one value must exist to calculate
102
114
  if (latestValues.size === 0) {
103
115
  return;
104
116
  }
105
117
 
106
- // Build context object for expression evaluation
107
118
  const context = {};
108
119
  const inputDetails = {};
120
+ const missingInputs = [];
109
121
 
110
122
  for (const input of node.inputMappings) {
111
123
  const data = latestValues.get(input.name);
112
- if (data) {
124
+ if (data && data.value !== undefined && data.value !== null) {
113
125
  context[input.name] = data.value;
114
126
  inputDetails[input.name] = {
115
127
  topic: data.topic,
@@ -118,63 +130,98 @@ module.exports = function(RED) {
118
130
  };
119
131
  } else {
120
132
  context[input.name] = undefined;
133
+ missingInputs.push(input.name);
121
134
  }
122
135
  }
123
136
 
124
- // Evaluate expression safely
137
+ // Build topics mapping: variable name -> topic
138
+ const topics = { _output: node.outputTopic };
139
+ const timestamps = {};
140
+ for (const [name, details] of Object.entries(inputDetails)) {
141
+ topics[name] = details.topic;
142
+ timestamps[name] = details.ts;
143
+ }
144
+
125
145
  try {
126
- // Create a function with named parameters from context + helpers
127
146
  const allParams = { ...helpers, ...context };
128
147
  const paramNames = Object.keys(allParams);
129
148
  const paramValues = Object.values(allParams);
130
149
 
131
- // Build function body with helpers and variables available
132
150
  const fn = new Function(...paramNames, `return ${node.expression};`);
133
151
  const result = fn(...paramValues);
134
152
 
153
+ // Check for NaN or invalid result
154
+ if (typeof result === 'number' && isNaN(result)) {
155
+ const errorMsg = {
156
+ topic: node.outputTopic,
157
+ payload: {
158
+ error: 'Expression resulted in NaN',
159
+ missingInputs: missingInputs,
160
+ expression: node.expression
161
+ },
162
+ inputs: inputDetails,
163
+ trigger: triggerTopic,
164
+ timestamp: Date.now()
165
+ };
166
+ node.send([null, errorMsg]);
167
+ node.status({ fill: "yellow", shape: "ring", text: "NaN" });
168
+ return;
169
+ }
170
+
135
171
  const msg = {
136
172
  topic: node.outputTopic,
137
173
  payload: result,
174
+ topics: topics,
138
175
  inputs: inputDetails,
176
+ timestamps: timestamps,
139
177
  expression: node.expression,
140
178
  trigger: triggerTopic,
141
179
  timestamp: Date.now()
142
180
  };
143
181
 
144
- node.send(msg);
182
+ node.send([msg, null]);
145
183
 
146
- // Store result back in cache so it can be used by other calculations
147
184
  node.cacheConfig.setValue(node.outputTopic, result, {
148
185
  source: 'event-calc',
149
186
  expression: node.expression,
150
187
  inputs: Object.keys(inputDetails)
151
188
  });
152
189
 
153
- // Update status with result (truncate if too long)
154
190
  const resultStr = String(result);
155
191
  const displayResult = resultStr.length > 15 ? resultStr.substring(0, 12) + '...' : resultStr;
156
192
  node.status({ fill: "green", shape: "dot", text: `= ${displayResult}` });
157
193
 
158
194
  } catch (err) {
195
+ const errorMsg = {
196
+ topic: node.outputTopic,
197
+ payload: {
198
+ error: err.message,
199
+ expression: node.expression,
200
+ context: context
201
+ },
202
+ inputs: inputDetails,
203
+ trigger: triggerTopic,
204
+ timestamp: Date.now()
205
+ };
206
+ node.send([null, errorMsg]);
159
207
  node.status({ fill: "red", shape: "ring", text: "eval error" });
160
- node.error(`Expression evaluation failed: ${err.message}`, { expression: node.expression, context: context });
161
208
  }
162
209
  }
163
210
 
164
- // Subscribe to each input pattern
211
+ // Subscribe to inputs
212
+ const latestValues = new Map();
213
+
165
214
  for (const input of node.inputMappings) {
166
- if (!input.name || !input.pattern) {
167
- continue;
168
- }
215
+ const topicName = input.topic || input.pattern;
216
+ if (!input.name || !topicName) continue;
169
217
 
170
- const subId = node.cacheConfig.subscribe(input.pattern, (topic, entry) => {
218
+ const subId = node.cacheConfig.subscribe(topicName, (topic, entry) => {
171
219
  latestValues.set(input.name, {
172
220
  topic: topic,
173
221
  value: entry.value,
174
222
  ts: entry.ts
175
223
  });
176
-
177
- tryCalculate(topic);
224
+ tryCalculate(topic, latestValues);
178
225
  });
179
226
  subscriptionIds.push(subId);
180
227
  }
@@ -193,9 +240,19 @@ module.exports = function(RED) {
193
240
  node.status({ fill: "blue", shape: "dot", text: "expr updated" });
194
241
  }
195
242
 
196
- // Force recalculation
243
+ // External trigger: any incoming message triggers calculation
244
+ if (node.externalTrigger) {
245
+ const triggerSource = msg.topic || '_external';
246
+ tryCalculate(triggerSource, latestValues);
247
+ done();
248
+ return;
249
+ }
250
+
251
+ // Force recalculation (use special topic to bypass self-output check)
197
252
  if (msg.payload === 'recalc' || msg.topic === 'recalc') {
198
- tryCalculate('manual');
253
+ if (latestValues.size > 0) {
254
+ tryCalculate('_recalc', latestValues);
255
+ }
199
256
  }
200
257
 
201
258
  done();
@@ -208,7 +265,6 @@ module.exports = function(RED) {
208
265
  }
209
266
  }
210
267
  subscriptionIds.length = 0;
211
- latestValues.clear();
212
268
  done();
213
269
  });
214
270
  }
@@ -0,0 +1,239 @@
1
+ <style>
2
+ .event-calc-white-text { fill: #ffffff !important; }
3
+ .red-ui-palette-node[data-palette-type="event-chart"] .red-ui-palette-label { color: #ffffff !important; }
4
+ </style>
5
+
6
+ <script type="text/javascript">
7
+ RED.nodes.registerType('event-chart', {
8
+ category: 'event calc',
9
+ color: '#758467',
10
+ defaults: {
11
+ name: { value: "" },
12
+ title: { value: "Event Chart" },
13
+ maxPoints: { value: 200, validate: RED.validators.number() },
14
+ timestampField: { value: "timestamp" },
15
+ valueField: { value: "payload" },
16
+ seriesField: { value: "topic" }
17
+ },
18
+ inputs: 1,
19
+ outputs: 0,
20
+ icon: "font-awesome/fa-line-chart",
21
+ paletteLabel: "chart",
22
+ label: function() {
23
+ return this.name || this.title || "Event Chart";
24
+ },
25
+ labelStyle: function() {
26
+ return (this.name ? "node_label_italic" : "") + " event-calc-white-text";
27
+ },
28
+ oneditprepare: function() {
29
+ var node = this;
30
+ var chartInstance = null;
31
+ var container = document.getElementById('event-chart-preview-container');
32
+
33
+ // Create canvas
34
+ var canvas = document.createElement('canvas');
35
+ canvas.id = 'event-chart-canvas-' + node.id;
36
+ canvas.style.width = '100%';
37
+ canvas.style.height = '100%';
38
+ container.innerHTML = '';
39
+ container.appendChild(canvas);
40
+
41
+ // Clear button
42
+ $('#event-chart-clear-btn').on('click', function() {
43
+ $.post('event-chart/' + node.id + '/clear', function(data) {
44
+ if (chartInstance) {
45
+ chartInstance.data.datasets = [];
46
+ chartInstance.update('none');
47
+ }
48
+ }).fail(function() {
49
+ RED.notify("Failed to clear chart", "error");
50
+ });
51
+ });
52
+
53
+ function loadScript(src) {
54
+ return new Promise(function(resolve, reject) {
55
+ if (document.querySelector('script[src="' + src + '"]')) {
56
+ resolve();
57
+ return;
58
+ }
59
+ var script = document.createElement('script');
60
+ script.src = src;
61
+ script.onload = resolve;
62
+ script.onerror = reject;
63
+ document.head.appendChild(script);
64
+ });
65
+ }
66
+
67
+ function initChart() {
68
+ if (chartInstance) {
69
+ chartInstance.destroy();
70
+ }
71
+
72
+ var ctx = document.getElementById('event-chart-canvas-' + node.id);
73
+ if (!ctx) return;
74
+
75
+ chartInstance = new Chart(ctx, {
76
+ type: 'line',
77
+ data: { datasets: [] },
78
+ options: {
79
+ responsive: true,
80
+ maintainAspectRatio: false,
81
+ animation: false,
82
+ scales: {
83
+ x: {
84
+ type: 'linear',
85
+ title: { display: true, text: 'Time' },
86
+ ticks: {
87
+ callback: function(value) {
88
+ return new Date(value).toLocaleTimeString();
89
+ }
90
+ }
91
+ },
92
+ y: { title: { display: true, text: 'Value' } }
93
+ },
94
+ plugins: {
95
+ legend: { position: 'top' },
96
+ title: { display: true, text: node.title || 'Event Chart' }
97
+ }
98
+ }
99
+ });
100
+
101
+ // Subscribe to chart data updates
102
+ RED.comms.subscribe("event-chart-data-" + node.id, function(topic, msg) {
103
+ if (chartInstance && msg && msg.data) {
104
+ updateChart(msg.data);
105
+ }
106
+ });
107
+
108
+ // Load initial data
109
+ $.getJSON('event-chart/' + node.id + '/data', function(data) {
110
+ if (data && data.data) {
111
+ updateChart(data.data);
112
+ }
113
+ });
114
+ }
115
+
116
+ function updateChart(data) {
117
+ if (!chartInstance) return;
118
+
119
+ var colors = [
120
+ '#2196F3', '#FF5722', '#4CAF50', '#9C27B0',
121
+ '#FF9800', '#00BCD4', '#E91E63', '#8BC34A',
122
+ '#3F51B5', '#CDDC39', '#009688', '#FFC107'
123
+ ];
124
+ var datasets = [];
125
+ var i = 0;
126
+ for (var series in data) {
127
+ datasets.push({
128
+ label: series,
129
+ data: data[series],
130
+ borderColor: colors[i % colors.length],
131
+ backgroundColor: colors[i % colors.length] + '20',
132
+ pointRadius: 2,
133
+ borderWidth: 2,
134
+ tension: 0.1,
135
+ fill: false
136
+ });
137
+ i++;
138
+ }
139
+ chartInstance.data.datasets = datasets;
140
+ chartInstance.update('none');
141
+ }
142
+
143
+ // Load Chart.js
144
+ if (typeof Chart === 'undefined') {
145
+ loadScript('https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js')
146
+ .then(function() {
147
+ setTimeout(initChart, 100);
148
+ })
149
+ .catch(function(err) {
150
+ container.innerHTML = '<p style="color:red">Failed to load Chart.js</p>';
151
+ });
152
+ } else {
153
+ initChart();
154
+ }
155
+ },
156
+ oneditcancel: function() {
157
+ RED.comms.unsubscribe("event-chart-data-" + this.id);
158
+ },
159
+ oneditsave: function() {
160
+ RED.comms.unsubscribe("event-chart-data-" + this.id);
161
+ }
162
+ });
163
+ </script>
164
+
165
+ <script type="text/html" data-template-name="event-chart">
166
+ <div class="form-row">
167
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
168
+ <input type="text" id="node-input-name" placeholder="Name">
169
+ </div>
170
+ <div class="form-row">
171
+ <label for="node-input-title"><i class="fa fa-header"></i> Title</label>
172
+ <input type="text" id="node-input-title" placeholder="Event Chart">
173
+ </div>
174
+ <div class="form-row">
175
+ <label for="node-input-valueField"><i class="fa fa-line-chart"></i> Value Field</label>
176
+ <input type="text" id="node-input-valueField" placeholder="payload">
177
+ </div>
178
+ <div class="form-row">
179
+ <label for="node-input-timestampField"><i class="fa fa-clock-o"></i> Timestamp Field</label>
180
+ <input type="text" id="node-input-timestampField" placeholder="timestamp">
181
+ </div>
182
+ <div class="form-row">
183
+ <label for="node-input-seriesField"><i class="fa fa-tags"></i> Series Field</label>
184
+ <input type="text" id="node-input-seriesField" placeholder="topic">
185
+ </div>
186
+ <div class="form-row">
187
+ <label for="node-input-maxPoints"><i class="fa fa-database"></i> Max Points</label>
188
+ <input type="number" id="node-input-maxPoints" placeholder="200">
189
+ </div>
190
+ <div class="form-row">
191
+ <label><i class="fa fa-area-chart"></i> Preview</label>
192
+ <div id="event-chart-preview-container" style="height: 250px; border: 1px solid #ccc; border-radius: 4px; padding: 5px; background: #fafafa;">
193
+ <p style="color:#888; text-align:center; padding-top:100px;">Loading chart...</p>
194
+ </div>
195
+ </div>
196
+ <div class="form-row">
197
+ <button type="button" id="event-chart-clear-btn" class="red-ui-button" style="width: 100%;">
198
+ <i class="fa fa-trash"></i> Clear Chart Data
199
+ </button>
200
+ </div>
201
+ </script>
202
+
203
+ <script type="text/html" data-help-name="event-chart">
204
+ <p>Displays time-series data on an interactive chart.</p>
205
+
206
+ <h3>Inputs</h3>
207
+ <dl class="message-properties">
208
+ <dt>payload <span class="property-type">number</span></dt>
209
+ <dd>The value to plot (configurable field name)</dd>
210
+ <dt class="optional">timestamp <span class="property-type">number</span></dt>
211
+ <dd>Unix timestamp in ms (defaults to current time)</dd>
212
+ <dt class="optional">topic <span class="property-type">string</span></dt>
213
+ <dd>Series name for grouping data (default: "default")</dd>
214
+ </dl>
215
+
216
+ <h3>Properties</h3>
217
+ <ul>
218
+ <li><b>Title</b> - Chart title displayed at top</li>
219
+ <li><b>Value Field</b> - Message property for value (default: payload)</li>
220
+ <li><b>Timestamp Field</b> - Message property for timestamp</li>
221
+ <li><b>Series Field</b> - Message property for series name</li>
222
+ <li><b>Max Points</b> - Maximum points per series (older points removed)</li>
223
+ </ul>
224
+
225
+ <h3>Usage</h3>
226
+ <p>Connect to any node that outputs numeric data. The chart will automatically:</p>
227
+ <ul>
228
+ <li>Group data by series (topic)</li>
229
+ <li>Display multiple series with different colors</li>
230
+ <li>Show real-time updates as data arrives</li>
231
+ <li>Limit data points to prevent memory issues</li>
232
+ </ul>
233
+
234
+ <h3>Clear Data</h3>
235
+ <p>Use the "Clear Chart Data" button, or send <code>msg.payload = "_clear"</code>.</p>
236
+
237
+ <h3>Preview</h3>
238
+ <p>Double-click the node to see a live preview of the chart with current data.</p>
239
+ </script>