diff --git a/rosboard/html/js/index.js b/rosboard/html/js/index.js
index 366a627..d1a5a3a 100644
--- a/rosboard/html/js/index.js
+++ b/rosboard/html/js/index.js
@@ -14,6 +14,7 @@ importJsOnce("js/viewers/PolygonViewer.js");
importJsOnce("js/viewers/DiagnosticViewer.js");
importJsOnce("js/viewers/TimeSeriesPlotViewer.js");
importJsOnce("js/viewers/PointCloud2Viewer.js");
+importJsOnce("js/viewers/ArrayPlotViewer.js");
// GenericViewer must be last
importJsOnce("js/viewers/GenericViewer.js");
diff --git a/rosboard/html/js/viewers/ArrayPlotViewer.js b/rosboard/html/js/viewers/ArrayPlotViewer.js
new file mode 100644
index 0000000..243f2c6
--- /dev/null
+++ b/rosboard/html/js/viewers/ArrayPlotViewer.js
@@ -0,0 +1,165 @@
+"use strict";
+
+// Plots time series data for any number of Float32 variables in Float32MultiArray.
+
+class ArrayPlotViewer extends Viewer {
+ /**
+ * Gets called when Viewer is first initialized.
+ * @override
+ **/
+ onCreate() {
+ this.viewerNode = $('
')
+ .css({ 'font-size': '11pt' })
+ .appendTo(this.card.content);
+
+ this.plotNode = $('')
+ .appendTo(this.viewerNode);
+
+ this.dataTable = $('')
+ .addClass('mdl-data-table')
+ .addClass('mdl-js-data-table')
+ .css({ 'width': '100%', 'table-layout': 'fixed' })
+ .appendTo(this.viewerNode);
+
+ this.lastData = {};
+ this.numFields = 0; // Will be dynamically updated
+ this.size = 500; // Buffer size
+ this.data = [[...new Array(this.size).fill(0)]]; // Initialize with timestamp
+
+ this.ptr = 0;
+ this.uplot = null; // To hold the dynamic uPlot instance
+
+ this.createEmptyPlot();
+ super.onCreate();
+ }
+
+ /**
+ * Dynamically creates or updates the plot based on the number of fields.
+ **/
+ createDynamicPlot(numFields) {
+ this.numFields = numFields;
+
+ let opts = {
+ id: "chart1",
+ class: "my-chart",
+ width: 300,
+ height: 200,
+ legend: {
+ show: true,
+ },
+ axes: [
+ {
+ stroke: "#a0a0a0",
+ ticks: {
+ stroke: "#404040",
+ },
+ grid: {
+ stroke: "#404040",
+ },
+ },
+ {
+ stroke: "#a0a0a0",
+ ticks: {
+ stroke: "#404040",
+ },
+ grid: {
+ stroke: "#404040",
+ },
+ },
+ ],
+ series: [
+ {}, // Timestamp (X-axis)
+ ...Array.from({ length: numFields }, (_, i) => ({
+ label: `Variable ${i + 1}`,
+ show: true,
+ spanGaps: false,
+ stroke: `hsl(${(i * 360) / numFields}, 100%, 50%)`,
+ width: 1,
+ })),
+ ],
+ };
+
+ // Initialize new data structure with dynamic field count
+ this.data = Array.from({ length: numFields + 1 }, () =>
+ new Array(this.size).fill(0)
+ );
+
+ // Destroy the old plot if it exists
+ if (this.uplot) {
+ this.uplot.destroy();
+ }
+
+ // Create a new plot instance
+ this.uplot = new uPlot(opts, this.data, this.plotNode[0]);
+
+ // Setup periodic update for the plot
+ setInterval(() => {
+ let chartData = [];
+ if (this.data[0][this.ptr] === 0) {
+ chartData = this.data.map((series) =>
+ series.slice(0, this.ptr)
+ );
+ } else {
+ chartData = this.data.map((series) =>
+ series.slice(this.ptr, this.size).concat(series.slice(0, this.ptr))
+ );
+ }
+ this.uplot.setSize({
+ width: this.plotNode[0].clientWidth,
+ height: 200,
+ });
+ this.uplot.setData(chartData);
+ }, 200);
+ }
+
+ /**
+ * Placeholder empty plot until data arrives.
+ **/
+ createEmptyPlot() {
+ this.createDynamicPlot(0); // Start with no fields
+ }
+
+ /**
+ * Called whenever new data is received for this Viewer.
+ * @override
+ **/
+ onData(msg) {
+ this.card.title.text(msg._topic_name);
+
+ // Infer the number of fields from the incoming data
+ const numFields = msg.data.length;
+
+ // Dynamically recreate plot if the field count changes
+ if (numFields !== this.numFields) {
+ this.createDynamicPlot(numFields);
+ }
+
+ // Update data for each field
+ this.data[0][this.ptr] = Math.floor(Date.now() / 10) / 100; // Timestamp
+ for (let i = 0; i < numFields; i++) {
+ this.data[i + 1][this.ptr] = msg.data[i];
+ }
+ this.ptr = (this.ptr + 1) % this.size;
+ }
+}
+
+// Register Viewer
+ArrayPlotViewer.friendlyName = "Dynamic Multi Float32 Time Series Plot";
+
+ArrayPlotViewer.supportedTypes = [
+ "std_msgs/msg/Float32MultiArray",
+ "std_msgs/msg/Float64MultiArray",
+ "std_msgs/msg/Int8MultiArray",
+ "std_msgs/msg/Int16MultiArray",
+ "std_msgs/msg/Int32MultiArray",
+ "std_msgs/msg/Int64MultiArray",
+ "std_msgs/msg/UInt8MultiArray",
+ "std_msgs/msg/UInt16MultiArray",
+ "std_msgs/msg/UInt32MultiArray",
+ "std_msgs/msg/UInt64MultiArray",
+];
+
+ArrayPlotViewer.maxUpdateRate = 100.0;
+
+Viewer.registerViewer(ArrayPlotViewer);
+