Provide utilities for convenient plotting of channels

Implement a set of utilities (along with basic examples) to
make it so that we can readily make plots of individual channels
from logfiles (or live on the robot).

Change-Id: Ic648c9ccb9dcb73419dc2c8c4c395fdea0536110
diff --git a/aos/network/BUILD b/aos/network/BUILD
index c68f3cc..fa5cacc 100644
--- a/aos/network/BUILD
+++ b/aos/network/BUILD
@@ -441,18 +441,12 @@
     name = "log_web_proxy_main",
     srcs = ["log_web_proxy_main.cc"],
     args = [
-        "--config=aos/network/www/test_config.json",
         "--data_dir=aos/network/www",
     ],
     copts = [
         "-DWEBRTC_POSIX",
         "-Wno-unused-parameter",
     ],
-    data = [
-        "//aos/network/www:plotting_sample",
-        "//y2020:config",
-        "@com_github_google_flatbuffers//:flatjs",
-    ],
     deps = [
         ":gen_embedded",
         ":web_proxy",
diff --git a/aos/network/log_web_proxy_main.cc b/aos/network/log_web_proxy_main.cc
index 525eb29..dda9ae4 100644
--- a/aos/network/log_web_proxy_main.cc
+++ b/aos/network/log_web_proxy_main.cc
@@ -16,7 +16,6 @@
 #include "seasocks/Server.h"
 #include "seasocks/WebSocket.h"
 
-DEFINE_string(config, "./config.json", "File path of aos configuration");
 DEFINE_string(data_dir, "www", "Directory to serve data files from");
 DEFINE_string(node, "", "Directory to serve data files from");
 DEFINE_int32(buffer_size, -1, "-1 if infinite, in # of messages / channel.");
diff --git a/aos/network/www/BUILD b/aos/network/www/BUILD
index 8f5bd49..a153ff3 100644
--- a/aos/network/www/BUILD
+++ b/aos/network/www/BUILD
@@ -102,11 +102,12 @@
 )
 
 ts_library(
-    name = "graph_main",
+    name = "aos_plotter",
     srcs = [
-        "graph_main.ts",
+        "aos_plotter.ts",
     ],
     target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
     deps = [
         ":plotter",
         ":proxy",
@@ -118,13 +119,23 @@
     ],
 )
 
-rollup_bundle(
-    name = "graph_main_bundle",
-    enable_code_splitting = False,
-    entry_point = "graph_main.ts",
+ts_library(
+    name = "demo_plot",
+    srcs = [
+        "demo_plot.ts",
+    ],
     target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
     deps = [
-        ":graph_main",
+        ":aos_plotter",
+        ":plotter",
+        ":proxy",
+        ":reflection_ts",
+        "//aos:configuration_ts_fbs",
+        "//aos/network:connect_ts_fbs",
+        "//aos/network:web_proxy_ts_fbs",
+        "//frc971/wpilib:imu_batch_ts_fbs",
+        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
     ],
 )
 
@@ -136,6 +147,7 @@
         "//aos:json_to_flatbuffer_flatbuffer",
     ],
     target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
     deps = [
         "//aos/events:config",
     ],
@@ -155,8 +167,6 @@
     name = "web_proxy_demo",
     srcs = ["web_proxy_demo.sh"],
     data = [
-        ":graph.html",
-        ":graph_main_bundle.min.js",
         ":reflection_test.html",
         ":reflection_test_bundle.min.js",
         ":test_config",
@@ -165,14 +175,3 @@
     ],
     target_compatible_with = ["@platforms//os:linux"],
 )
-
-filegroup(
-    name = "plotting_sample",
-    srcs = [
-        "graph.html",
-        "graph_main_bundle.min.js",
-        "styles.css",
-        "test_config",
-    ],
-    visibility = ["//visibility:public"],
-)
diff --git a/aos/network/www/aos_plotter.ts b/aos/network/www/aos_plotter.ts
new file mode 100644
index 0000000..33a3de1
--- /dev/null
+++ b/aos/network/www/aos_plotter.ts
@@ -0,0 +1,185 @@
+// This library provides a wrapper around our WebGL plotter that makes it
+// easy to plot AOS messages/channels as time series.
+//
+// This is works by subscribing to each channel that we want to plot, storing
+// all the messages for that channel, and then periodically running through
+// every message and extracting the fields to plot.
+// It is also possible to insert code to make modifications to the messages
+// as we read/process them, as is the case for the IMU processing code (see
+// //frc971/wpilib:imu*.ts) where each message is actually a batch of several
+// individual messages that need to be plotted as separate points.
+//
+// The basic flow for using the AosPlotter is:
+// // 1) Construct the plotter
+// const aosPlotter = new AosPlotter(connection);
+// // 2) Add messages sources that we'll want to subscribe to.
+// const source = aosPlotter.addMessageSource('/aos', 'aos.timing.Report');
+// // 3) Create figures at defined positions within a given HTML element..
+// const timingPlot = aosPlotter.addPlot(parentDiv, [0, 0], [width, height]);
+// // 4) Add specific signals to each figure, using the message sources you
+//       defined at the start.
+// timingPlot.addMessageLine(source, ['pid']);
+//
+// The demo_plot.ts script has a basic example of using this library, with all
+// the required boilerplate, as well as some extra examples about how to
+// add axis labels and the such.
+import * as configuration from 'org_frc971/aos/configuration_generated';
+import {Line, Plot} from 'org_frc971/aos/network/www/plotter';
+import * as proxy from 'org_frc971/aos/network/www/proxy';
+import * as web_proxy from 'org_frc971/aos/network/web_proxy_generated';
+import * as reflection from 'org_frc971/aos/network/www/reflection'
+import * as flatbuffers_builder from 'org_frc971/external/com_github_google_flatbuffers/ts/builder';
+import {ByteBuffer} from 'org_frc971/external/com_github_google_flatbuffers/ts/byte-buffer';
+
+import Channel = configuration.aos.Channel;
+import Connection = proxy.Connection;
+import Configuration = configuration.aos.Configuration;
+import Schema = configuration.reflection.Schema;
+import Parser = reflection.Parser;
+import Table = reflection.Table;
+import SubscriberRequest = web_proxy.aos.web_proxy.SubscriberRequest;
+import ChannelRequest = web_proxy.aos.web_proxy.ChannelRequest;
+import TransferMethod = web_proxy.aos.web_proxy.TransferMethod;
+
+export class TimestampedMessage {
+  constructor(
+      public readonly message: Table, public readonly time: number) {}
+}
+
+// The MessageHandler stores an array of every single message on a given channel
+// and then supplies individual fields as arrays when requested. Currently this
+// is very much unoptimized and re-processes the entire array of messages on
+// every call to getField().
+export class MessageHandler {
+  protected parser: Parser;
+  protected messages: TimestampedMessage[] = [];
+  constructor(schema: Schema) {
+    this.parser = new Parser(schema);
+  }
+  addMessage(data: Uint8Array, time: number): void {
+    this.messages.push(
+        new TimestampedMessage(Table.getRootTable(new ByteBuffer(data)), time));
+  }
+  // Returns a time-series of every single instance of the given field. Format
+  // of the return value is [time0, value0, time1, value1,... timeN, valueN],
+  // to match with the Line.setPoint() interface.
+  // By convention, NaN is used to indicate that a message existed at a given
+  // timestamp but the requested field was not populated.
+  getField(field: string[]): Float32Array {
+    const fieldName = field[field.length - 1];
+    const subMessage = field.slice(0, field.length - 1);
+    const results = new Float32Array(this.messages.length * 2);
+    for (let ii = 0; ii < this.messages.length; ++ii) {
+      let message = this.messages[ii].message;
+      for (const subMessageName of subMessage) {
+        message = this.parser.readTable(message, subMessageName);
+        if (message === undefined) {
+          break;
+        }
+      }
+      results[ii * 2] = this.messages[ii].time;
+      if (message === undefined) {
+        results[ii * 2 + 1] = NaN;
+      } else {
+        results[ii * 2 + 1] = this.parser.readScalar(message, fieldName);
+      }
+    }
+    return results;
+  }
+  numMessages(): number {
+    return this.messages.length;
+  }
+}
+
+class MessageLine {
+  constructor(
+      public readonly messages: MessageHandler, public readonly line: Line,
+      public readonly field: string[]) {}
+}
+
+class AosPlot {
+  private lines: MessageLine[] = [];
+  constructor(
+      private readonly plotter: AosPlotter, public readonly plot: Plot) {}
+
+  // Adds a line to the figure.
+  // message specifies what channel/data source to pull from, and field
+  // specifies the field within that channel. field is an array specifying
+  // the full path to the field within the message. For instance, to
+  // plot whether the drivetrain is currently zeroed based on the drivetrain
+  // status message, you would specify the ['zeroing', 'zeroed'] field to
+  // get the DrivetrainStatus.zeroing().zeroed() member.
+  // Currently, this interface does not provide any support for non-numeric
+  // fields or for repeated fields (or sub-messages) of any sort.
+  addMessageLine(message: MessageHandler, field: string[]): Line {
+    const line = this.plot.getDrawer().addLine();
+    line.setLabel(field.join('.'));
+    this.lines.push(new MessageLine(message, line, field));
+    return line;
+  }
+
+  draw(): void {
+    // Only redraw lines if the number of points has changed--because getField()
+    // is a relatively expensive call, we don't want to do it any more than
+    // necessary.
+    for (const line of this.lines) {
+      if (line.messages.numMessages() * 2 != line.line.getPoints().length) {
+        line.line.setPoints(line.messages.getField(line.field));
+      }
+    }
+  }
+}
+
+export class AosPlotter {
+  private plots: AosPlot[] = [];
+  private messages = new Set<MessageHandler>();
+  constructor(private readonly connection: Connection) {
+    // Set up to redraw at some regular interval. The exact rate is unimportant.
+    setInterval(() => {
+      this.draw();
+    }, 100);
+  }
+
+  // Sets up an AOS channel as a message source. Returns a handler that can
+  // be passed to addMessageLine().
+  addMessageSource(name: string, type: string): MessageHandler {
+    return this.addRawMessageSource(
+        name, type, new MessageHandler(this.connection.getSchema(type)));
+  }
+
+  // Same as addMessageSource, but allows you to specify a custom MessageHandler
+  // that does some processing on the requested message. This allows you to
+  // create post-processed versions of individual channels.
+  addRawMessageSource(
+      name: string, type: string,
+      messageHandler: MessageHandler): MessageHandler {
+    this.messages.add(messageHandler);
+    // Use a "reliable" handler so that we get *all* the data when we are
+    // plotting from a logfile.
+    this.connection.addReliableHandler(
+        name, type, (data: Uint8Array, time: number) => {
+          messageHandler.addMessage(data, time);
+        });
+    return messageHandler;
+  }
+  // Add a new figure at the provided position with the provided size within
+  // parentElement.
+  addPlot(parentElement: Element, topLeft: number[], size: number[]): AosPlot {
+    const div = document.createElement("div");
+    div.style.top = topLeft[1].toString();
+    div.style.left = topLeft[0].toString();
+    div.style.position = 'absolute';
+    parentElement.appendChild(div);
+    const newPlot = new Plot(div, size[0], size[1]);
+    for (let plot of this.plots.values()) {
+      newPlot.linkXAxis(plot.plot);
+    }
+    this.plots.push(new AosPlot(this, newPlot));
+    return this.plots[this.plots.length - 1];
+  }
+  private draw(): void {
+    for (const plot of this.plots) {
+      plot.draw();
+    }
+  }
+}
diff --git a/aos/network/www/config_handler.ts b/aos/network/www/config_handler.ts
index df2d4f8..73b6b89 100644
--- a/aos/network/www/config_handler.ts
+++ b/aos/network/www/config_handler.ts
@@ -72,14 +72,8 @@
       const channel = this.config.channels(Number(index));
       this.connection.addHandler(
           channel.name(), channel.type(), (data, time) => {
-            const config = this.connection.getConfig();
-            let schema = null;
-            for (let ii = 0; ii < config.channelsLength(); ++ii) {
-              if (config.channels(ii).type() === channel.type()) {
-                schema = config.channels(ii).schema();
-              }
-            }
-            const parser = new Parser(schema);
+            const parser =
+                new Parser(this.connection.getSchema(channel.type()));
             console.log(
                 parser.toObject(Table.getRootTable(new ByteBuffer(data))));
           });
diff --git a/aos/network/www/demo_plot.ts b/aos/network/www/demo_plot.ts
new file mode 100644
index 0000000..d223757
--- /dev/null
+++ b/aos/network/www/demo_plot.ts
@@ -0,0 +1,74 @@
+// This file provides a basic demonstration of the plotting functionality.
+// The plotDemo() function provided here is called by
+// //frc971/analysis:plot_index.ts
+// To view the demo plot, run
+// bazel run -c opt //frc971/analysis:live_web_plotter_demo
+// And then navigate to
+// http://localhost:8080/?plot=Demo
+// The plot=Demo isn't structly necessary, but ensures that this plot is
+// selected by default--otherwise, you'll need to select "Demo" from the
+// drop-down menu.
+//
+// This example shows how to:
+// (a) Make use of the AosPlotter to plot a shmem message as a time-series.
+// (b) Define your own custom plot with whatever data you want.
+import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
+import {Plot} from 'org_frc971/aos/network/www/plotter';
+import * as proxy from 'org_frc971/aos/network/www/proxy';
+
+import Connection = proxy.Connection;
+
+export function plotDemo(conn: Connection, parentDiv: Element): void {
+  const width = 900;
+  const height = 400;
+
+  const benchmarkDiv = document.createElement('div');
+  benchmarkDiv.style.top = height.toString();
+  benchmarkDiv.style.left = '0';
+  benchmarkDiv.style.position = 'absolute';
+  parentDiv.appendChild(benchmarkDiv);
+
+  const benchmarkPlot = new Plot(benchmarkDiv, width, height);
+
+  const aosPlotter = new AosPlotter(conn);
+
+  {
+    // Setup a plot that just shows the PID of each timing report message.
+    // For the basic live_web_plotter_demo, this will be a boring line showing
+    // just the PID of the proxy process. On a real system, or against a logfile,
+    // this would show the PIDs of all active processes.
+    const timingReport =
+        aosPlotter.addMessageSource('/aos', 'aos.timing.Report');
+    const timingPlot =
+        aosPlotter.addPlot(parentDiv, [0, 0], [width, height]);
+    timingPlot.plot.getAxisLabels().setTitle('Timing Report PID');
+    timingPlot.plot.getAxisLabels().setYLabel('PID');
+    timingPlot.plot.getAxisLabels().setXLabel('Monotonic Send Time (sec)');
+    const msgLine = timingPlot.addMessageLine(timingReport, ['pid']);
+    msgLine.setDrawLine(false);
+    msgLine.setPointSize(5);
+  }
+
+  // Set up and draw the benchmarking plot.
+  benchmarkPlot.getAxisLabels().setTitle(
+      'Benchmarking plot (1M points per line)');
+  const line1 = benchmarkPlot.getDrawer().addLine();
+  // For demonstration purposes, make line1 only have points and line2 only have
+  // lines.
+  line1.setDrawLine(false);
+  line1.setLabel('LINE ONE');
+  const line2 = benchmarkPlot.getDrawer().addLine();
+  line2.setPointSize(0);
+  line2.setLabel('LINE TWO');
+  const NUM_POINTS = 1000000;
+  const points1 = [];
+  const points2 = [];
+  for (let ii = 0; ii < NUM_POINTS; ++ii) {
+    points1.push(ii);
+    points2.push(ii);
+    points1.push(Math.sin(ii * 10 / NUM_POINTS));
+    points2.push(Math.cos(ii * 10 / NUM_POINTS));
+  }
+  line1.setPoints(new Float32Array(points1));
+  line2.setPoints(new Float32Array(points2));
+}
diff --git a/aos/network/www/graph.html b/aos/network/www/graph.html
deleted file mode 100644
index 0f66bb0..0000000
--- a/aos/network/www/graph.html
+++ /dev/null
@@ -1,9 +0,0 @@
-<html>
-  <head>
-    <script src="graph_main_bundle.min.js" defer></script>
-    <link rel="stylesheet" href="styles.css">
-  </head>
-  <body>
-  </body>
-</html>
-
diff --git a/aos/network/www/graph_main.ts b/aos/network/www/graph_main.ts
deleted file mode 100644
index 2548e1d..0000000
--- a/aos/network/www/graph_main.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-// This file creates a set of two demonstration plots for testing out the
-// plotting utility.
-// To run this and see a demo, run:
-// bazel run -c opt //aos/network/www:web_proxy_demo
-// And then open localhost:8080/graph.html
-//
-// The plots are just the plots of the handler latency / run times of the first
-// timer in the the /aos timing report message (this message was chosen because
-// it is already being published by the web proxy process, so the demo requires
-// very little setup).
-import * as configuration from 'org_frc971/aos/configuration_generated';
-import {Line, Plot} from 'org_frc971/aos/network/www/plotter';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import * as web_proxy from 'org_frc971/aos/network/web_proxy_generated';
-import * as reflection from 'org_frc971/aos/network/www/reflection'
-import * as flatbuffers_builder from 'org_frc971/external/com_github_google_flatbuffers/ts/builder';
-import {ByteBuffer} from 'org_frc971/external/com_github_google_flatbuffers/ts/byte-buffer';
-
-import Channel = configuration.aos.Channel;
-import Connection = proxy.Connection;
-import Configuration = configuration.aos.Configuration;
-import Parser = reflection.Parser;
-import Table = reflection.Table;
-import SubscriberRequest = web_proxy.aos.web_proxy.SubscriberRequest;
-import ChannelRequest = web_proxy.aos.web_proxy.ChannelRequest;
-import TransferMethod = web_proxy.aos.web_proxy.TransferMethod;
-
-const width = 900;
-const height = 400;
-
-const helpDiv = document.createElement('div');
-helpDiv.style.top = '0';
-helpDiv.style.left = '0';
-helpDiv.style.position = 'absolute';
-document.body.appendChild(helpDiv);
-helpDiv.innerHTML =
-    'Help: click + drag to pan, double click to reset, scroll to zoom. ' +
-    'Hold the x/y keys to only pan/zoom along the x/y axes.<br>' +
-    'X-axes of the top two plots are linked together.';
-
-const div = document.createElement('div');
-div.style.top = '40';
-div.style.left = '0';
-div.style.position = 'absolute';
-document.body.appendChild(div);
-
-const div2 = document.createElement('div');
-div2.style.top = (parseFloat(div.style.top) + height).toString();
-div2.style.left = '0';
-div2.style.position = 'absolute';
-document.body.appendChild(div2);
-
-const benchmarkDiv = document.createElement('div');
-benchmarkDiv.style.top = (parseFloat(div2.style.top) + height).toString();
-benchmarkDiv.style.left = '0';
-benchmarkDiv.style.position = 'absolute';
-document.body.appendChild(benchmarkDiv);
-
-const handlerTimePlot = new Plot(div, width, height);
-const latencyPlot = new Plot(div2, width, height);
-const benchmarkPlot = new Plot(benchmarkDiv, width, height);
-
-// Class to store the lines for plotting a Statistics message.
-class StatLines {
-  private max: Line;
-  private min: Line;
-  private average: Line;
-  private maxPoints: number[] = [];
-  private minPoints: number[] = [];
-  private averagePoints: number[] = [];
-  constructor(plotter: Plot) {
-    this.max = plotter.getDrawer().addLine();
-    this.min = plotter.getDrawer().addLine();
-    this.average = plotter.getDrawer().addLine();
-
-    this.max.setLabel("Max");
-    this.min.setLabel("Min");
-    this.average.setLabel("Average");
-
-    this.max.setColor([1, 0, 0]);
-    this.min.setColor([0, 1, 0]);
-    this.average.setColor([0, 0, 1]);
-  }
-  addPoints(parser: Parser, statsTable: Table, time: number) {
-    this.maxPoints.push(time);
-    this.minPoints.push(time);
-    this.averagePoints.push(time);
-    this.maxPoints.push(parser.readScalar(statsTable, "max") * 1000);
-    this.minPoints.push(parser.readScalar(statsTable, "min") * 1000);
-    this.averagePoints.push(parser.readScalar(statsTable, "average") * 1000);
-
-
-    // TODO: These memory allocations absolutely kill performance.
-    this.max.setPoints(new Float32Array(this.maxPoints));
-    this.min.setPoints(new Float32Array(this.minPoints));
-    this.average.setPoints(new Float32Array(this.averagePoints));
-  }
-}
-
-function setupPlot(plotter: Plot, title: string): StatLines {
-  plotter.getAxisLabels().setXLabel("Monotonic send time since start (sec)");
-  plotter.getAxisLabels().setYLabel("Time (msec)");
-  plotter.getAxisLabels().setTitle(title);
-  return new StatLines(plotter);
-}
-
-// Sets of the two x-axes to be shared; remove this to be able to zoom/pan the
-// x-axes independently.
-handlerTimePlot.linkXAxis(latencyPlot);
-
-const handlerLines = setupPlot(handlerTimePlot, "Handler Run Times");
-const latencyLines = setupPlot(latencyPlot, "Handler Latencies");
-
-const conn = new Connection();
-
-conn.connect();
-
-const timingReport = {
-  name: '/aos',
-  type: 'aos.timing.Report',
-};
-
-let reportParser = null;
-let startTime = null;
-
-conn.addConfigHandler((config: Configuration) => {
-  // Locate the timing report schema so that we can read the received messages.
-  let reportSchema = null;
-  for (let ii = 0; ii < config.channelsLength(); ++ii) {
-    if (config.channels(ii).type() === timingReport.type) {
-      reportSchema = config.channels(ii).schema();
-    }
-  }
-  if (reportSchema === null) {
-    throw new Error('Couldn\'t find timing report schema in config.');
-  }
-  reportParser = new Parser(reportSchema);
-
-  conn.addReliableHandler(
-      timingReport.name, timingReport.type,
-      (data: Uint8Array, time: number) => {
-        if (startTime === null) {
-          startTime = time;
-        }
-        time = time - startTime;
-        const table = Table.getRootTable(new ByteBuffer(data));
-
-        const timer = reportParser.readVectorOfTables(table, 'timers')[0];
-        handlerLines.addPoints(
-            reportParser, reportParser.readTable(timer, 'handler_time'), time);
-        latencyLines.addPoints(
-            reportParser, reportParser.readTable(timer, 'wakeup_latency'),
-            time);
-      });
-});
-
-// Set up and draw the benchmarking plot
-benchmarkPlot.getAxisLabels().setTitle(
-    'Benchmarkping plot (1M points per line)');
-const line1 = benchmarkPlot.getDrawer().addLine();
-// For demonstration purposes, make line1 only have points and line2 only have
-// lines.
-line1.setDrawLine(false);
-line1.setLabel('LINE ONE');
-const line2 = benchmarkPlot.getDrawer().addLine();
-line2.setPointSize(0);
-line2.setLabel('LINE TWO');
-const NUM_POINTS = 1000000;
-const points1 = [];
-const points2 = [];
-for (let ii = 0; ii < NUM_POINTS; ++ii) {
-  points1.push(ii);
-  points2.push(ii);
-  points1.push(Math.sin(ii * 10 / NUM_POINTS));
-  points2.push(Math.cos(ii * 10 / NUM_POINTS));
-}
-line1.setPoints(new Float32Array(points1));
-line2.setPoints(new Float32Array(points2));
diff --git a/aos/network/www/plotter.ts b/aos/network/www/plotter.ts
index d33953c..bbf5205 100644
--- a/aos/network/www/plotter.ts
+++ b/aos/network/www/plotter.ts
@@ -130,6 +130,10 @@
     }
   }
 
+  getPoints(): Float32Array {
+    return this.points;
+  }
+
   // Get/set the label to use for the line when drawing the legend.
   setLabel(label: string) {
     this._label = label;
@@ -231,7 +235,7 @@
   // Location, in pixels, of the legend in the text canvas.
   private location: number[] = [0, 0];
   constructor(private ctx: CanvasRenderingContext2D, private lines: Line[]) {
-    this.location = [this.ctx.canvas.width - 100, 30];
+    this.location = [80, 30];
   }
 
   setPosition(location: number[]): void {
@@ -501,9 +505,6 @@
     for (let line of this.lines) {
       minValues = cwiseOp(minValues, line.minValues(), Math.min);
     }
-    if (!isFinite(minValues[0]) || !isFinite(minValues[1])) {
-      return [0, 0];
-    }
     return minValues;
   }
 
@@ -512,9 +513,6 @@
     for (let line of this.lines) {
       maxValues = cwiseOp(maxValues, line.maxValues(), Math.max);
     }
-    if (!isFinite(maxValues[0]) || !isFinite(maxValues[1])) {
-      return [0, 0];
-    }
     return maxValues;
   }
 
@@ -740,6 +738,7 @@
   private autoFollow: boolean = true;
   private linkedXAxes: Plot[] = [];
   private lastTimeMs: number = 0;
+  private defaultYRange: number[]|null = null;
 
   constructor(wrapperDiv: HTMLDivElement, width: number, height: number) {
     wrapperDiv.appendChild(this.canvas);
@@ -889,9 +888,35 @@
     this.setZoom(scale, offset);
   }
 
+  setDefaultYRange(range: number[]|null) {
+    if (range == null) {
+      this.defaultYRange = null;
+      return;
+    }
+    if (range.length != 2) {
+      throw new Error('Range should contain exactly two values.');
+    }
+    this.defaultYRange = range;
+  }
+
   resetZoom() {
     const minValues = this.drawer.minValues();
     const maxValues = this.drawer.maxValues();
+    for (const plot of this.linkedXAxes) {
+      const otherMin = plot.drawer.minValues();
+      const otherMax = plot.drawer.maxValues();
+      // For linked x-axes, only adjust the x limits.
+      minValues[0] = Math.min(minValues[0], otherMin[0]);
+      maxValues[0] = Math.max(maxValues[0], otherMax[0]);
+    }
+    if (!isFinite(minValues[0]) || !isFinite(maxValues[0])) {
+      minValues[0] = 0;
+      maxValues[0] = 0;
+    }
+    if (!isFinite(minValues[1]) || !isFinite(maxValues[1])) {
+      minValues[1] = 0;
+      maxValues[1] = 0;
+    }
     if (minValues[0] == maxValues[0]) {
       minValues[0] -= 1;
       maxValues[0] += 1;
@@ -900,6 +925,10 @@
       minValues[1] -= 1;
       maxValues[1] += 1;
     }
+    if (this.defaultYRange != null) {
+      minValues[1] = this.defaultYRange[0];
+      maxValues[1] = this.defaultYRange[1];
+    }
     this.setZoomCorners(minValues, maxValues);
     this.autoFollow = true;
     for (let plot of this.linkedXAxes) {
diff --git a/aos/network/www/proxy.ts b/aos/network/www/proxy.ts
index 4fbba85..948f8db 100644
--- a/aos/network/www/proxy.ts
+++ b/aos/network/www/proxy.ts
@@ -5,6 +5,7 @@
 
 import ChannelFb = configuration.aos.Channel;
 import Configuration = configuration.aos.Configuration;
+import Schema = configuration.reflection.Schema;
 import MessageHeader = web_proxy.aos.web_proxy.MessageHeader;
 import WebSocketIce = web_proxy.aos.web_proxy.WebSocketIce;
 import WebSocketMessage = web_proxy.aos.web_proxy.WebSocketMessage;
@@ -83,7 +84,7 @@
   private readonly configHandlers = new Set<(config: Configuration) => void>();
 
   private readonly handlerFuncs =
-      new Map<string, (data: Uint8Array, sentTime: number) => void>();
+      new Map<string, ((data: Uint8Array, sentTime: number) => void)[]>();
   private readonly handlers = new Set<Handler>();
 
   private subscribedChannels: ChannelRequest[] = [];
@@ -122,10 +123,27 @@
       handler: (data: Uint8Array, sentTime: number) => void): void {
     const channel = new Channel(name, type);
     const request = new ChannelRequest(channel, method);
-    this.handlerFuncs.set(channel.key(), handler);
+    if (!this.handlerFuncs.has(channel.key())) {
+      this.handlerFuncs.set(channel.key(), []);
+    }
+    this.handlerFuncs.get(channel.key()).push(handler);
     this.subscribeToChannel(request);
   }
 
+  getSchema(typeName: string): Schema {
+    let schema = null;
+    const config = this.getConfig();
+    for (let ii = 0; ii < config.channelsLength(); ++ii) {
+      if (config.channels(ii).type() === typeName) {
+        schema = config.channels(ii).schema();
+      }
+    }
+    if (schema === null) {
+      throw new Error('Unable to find schema for ' + typeName);
+    }
+    return schema;
+  }
+
   subscribeToChannel(channel: ChannelRequest): void {
     this.subscribedChannels.push(channel);
     if (this.configInternal === null) {
@@ -183,8 +201,10 @@
   onDataChannel(ev: RTCDataChannelEvent): void {
     const channel = ev.channel;
     const name = channel.label;
-    const handlerFunc = this.handlerFuncs.get(name);
-    this.handlers.add(new Handler(handlerFunc, channel));
+    const handlers = this.handlerFuncs.get(name);
+    for (const handler of handlers) {
+      this.handlers.add(new Handler(handler, channel));
+    }
   }
 
   onIceCandidate(e: RTCPeerConnectionIceEvent): void {
diff --git a/aos/network/www/reflection.ts b/aos/network/www/reflection.ts
index 9c21a17..2e1ed8f 100644
--- a/aos/network/www/reflection.ts
+++ b/aos/network/www/reflection.ts
@@ -120,6 +120,16 @@
   static getRootTable(bb: ByteBuffer): Table {
     return new Table(bb, -1, bb.readInt32(bb.position()) + bb.position());
   }
+  static getNamedTable(
+      bb: ByteBuffer, schema: reflection.Schema, type: string,
+      offset: number): Table {
+    for (let ii = 0; ii < schema.objectsLength(); ++ii) {
+      if (schema.objects(ii).name() == type) {
+        return new Table(bb, ii, offset);
+      }
+    }
+    throw new Error('Unable to find type ' + type + ' in schema.');
+  }
   // Reads a scalar of a given type at a given offset.
   readScalar(fieldType: reflection.BaseType, offset: number) {
     switch (fieldType) {
diff --git a/frc971/analysis/BUILD b/frc971/analysis/BUILD
index 61355b0..465df0d 100644
--- a/frc971/analysis/BUILD
+++ b/frc971/analysis/BUILD
@@ -1,6 +1,8 @@
 package(default_visibility = ["//visibility:public"])
 
+load("@npm_bazel_typescript//:defs.bzl", "ts_library")
 load("@com_google_protobuf//:protobuf.bzl", "py_proto_library")
+load("@build_bazel_rules_nodejs//:defs.bzl", "nodejs_binary", "rollup_bundle")
 
 py_binary(
     name = "plot_action",
@@ -81,3 +83,54 @@
     target_compatible_with = ["@platforms//os:linux"],
     deps = [":plot"],
 )
+
+ts_library(
+    name = "plot_index",
+    srcs = ["plot_index.ts"],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        "//aos:configuration_ts_fbs",
+        "//aos/network/www:demo_plot",
+        "//aos/network/www:proxy",
+        "//frc971/wpilib:imu_plotter",
+    ],
+)
+
+rollup_bundle(
+    name = "plot_index_bundle",
+    enable_code_splitting = False,
+    entry_point = "plot_index.ts",
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":plot_index",
+    ],
+)
+
+filegroup(
+    name = "plotter_files",
+    srcs = [
+        "index.html",
+        "plot_index_bundle.min.js",
+    ],
+)
+
+sh_binary(
+    name = "web_plotter",
+    srcs = ["web_plotter.sh"],
+    data = [
+        ":plotter_files",
+        "//aos/network:log_web_proxy_main",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+)
+
+sh_binary(
+    name = "live_web_plotter_demo",
+    srcs = ["live_web_plotter_demo.sh"],
+    data = [
+        ":plotter_files",
+        "//aos/network:web_proxy_main",
+        "//aos/network/www:test_config",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+)
diff --git a/frc971/analysis/index.html b/frc971/analysis/index.html
new file mode 100644
index 0000000..edd2483
--- /dev/null
+++ b/frc971/analysis/index.html
@@ -0,0 +1,8 @@
+<html>
+  <head>
+    <script src="plot_index_bundle.min.js" defer></script>
+  </head>
+  <body>
+  </body>
+</html>
+
diff --git a/frc971/analysis/live_web_plotter_demo.sh b/frc971/analysis/live_web_plotter_demo.sh
new file mode 100755
index 0000000..4c4c9c4
--- /dev/null
+++ b/frc971/analysis/live_web_plotter_demo.sh
@@ -0,0 +1 @@
+./aos/network/web_proxy_main --config=aos/network/www/test_config.json --data_dir=frc971/analysis
diff --git a/frc971/analysis/plot_index.ts b/frc971/analysis/plot_index.ts
new file mode 100644
index 0000000..759202a
--- /dev/null
+++ b/frc971/analysis/plot_index.ts
@@ -0,0 +1,118 @@
+// This file provides an index of all standard plot configs.
+// In the future, this may be split into more pieces (e.g., to have
+// year-specific indices). For now, the pattern for creating a new plot
+// is that you provide an exported function (a la the plot*() functions imported
+// below) which, when called, will generate the plot in the provided div.
+// This file handles providing a master list of all known plots so that
+// the user can just open a single web-page and select the plot that they want
+// from a drop-down. A given plot will not be loaded until it has been selected
+// once, at which point it will stay loaded. This means that if the user wants
+// to switch between several plot configs, they don't incur any performance
+// penalty associated with swapping.
+// By default, no plot is selected, but the plot= URL parameter may be used
+// to specify a specific plot, so that people can create links to a specific
+// plot.
+// The plot*() functions are called *after* we have already received a valid
+// config from the web server, so config handlers do not need to be used.
+//
+// The exact setup of this will be in flux as we add more configs and figure out
+// what setups work best--we will likely end up with separate index files for
+// each robot year, and may even end up allowing plots to be specified solely
+// using JSON rather than requiring people to write a script just to create
+// a plot.
+import * as configuration from 'org_frc971/aos/configuration_generated';
+import * as proxy from 'org_frc971/aos/network/www/proxy';
+import {plotImu} from 'org_frc971/frc971/wpilib/imu_plotter';
+import {plotDemo} from 'org_frc971/aos/network/www/demo_plot';
+
+import Connection = proxy.Connection;
+import Configuration = configuration.aos.Configuration;
+
+const rootDiv = document.createElement('div');
+document.body.appendChild(rootDiv);
+
+const helpDiv = document.createElement('div');
+rootDiv.appendChild(helpDiv);
+helpDiv.innerHTML =
+    'Help: click + drag to pan, double click to reset, scroll to zoom. ' +
+    'Hold the x/y keys to only pan/zoom along the x/y axes.';
+
+class PlotState {
+  public readonly div: HTMLElement;
+  private initialized = false;
+  constructor(
+      parentDiv: HTMLElement,
+      private readonly initializer:
+          (conn: Connection, element: Element) => void) {
+    this.div = document.createElement('div');
+    parentDiv.appendChild(this.div);
+    this.hide();
+  }
+  initialize(conn: Connection): void {
+    if (!this.initialized) {
+      this.initializer(conn, this.div);
+      this.initialized = true;
+    }
+  }
+  hide(): void {
+    this.div.style.display = "none";
+  }
+  show(): void {
+    this.div.style.display = "block";
+  }
+}
+
+const plotSelect = document.createElement('select');
+rootDiv.appendChild(plotSelect);
+
+const plotDiv = document.createElement('div');
+plotDiv.style.top = (plotSelect.getBoundingClientRect().bottom + 10).toString();
+plotDiv.style.left = '0';
+plotDiv.style.position = 'absolute';
+rootDiv.appendChild(plotDiv);
+
+// The master list of all the plots that we provide. For a given config, it
+// is possible that not all of these plots will be usable depending on the
+// presence of certain channels.
+const plotIndex = new Map<string, PlotState>([
+  ['Demo', new PlotState(plotDiv, plotDemo)],
+  ['IMU', new PlotState(plotDiv, plotImu)]
+]);
+
+const invalidSelectValue = 'null';
+function getDefaultPlot(): string {
+  const urlParams = (new URL(document.URL)).searchParams;
+  const urlParamKey = 'plot';
+  if (!urlParams.has(urlParamKey)) {
+    return invalidSelectValue;
+  }
+  const desiredPlot = urlParams.get(urlParamKey);
+  if (!plotIndex.has(desiredPlot)) {
+    return invalidSelectValue;
+  }
+  return desiredPlot;
+}
+
+const conn = new Connection();
+
+conn.connect();
+
+conn.addConfigHandler((config: Configuration) => {
+  plotSelect.add(new Option("Select Plot", invalidSelectValue));
+  for (const name of plotIndex.keys()) {
+    plotSelect.add(new Option(name, name));
+  }
+  plotSelect.addEventListener('input', () => {
+    for (const plot of plotIndex.values()) {
+      plot.hide();
+    }
+    if (plotSelect.value == invalidSelectValue) {
+      return;
+    }
+    plotIndex.get(plotSelect.value).initialize(conn);
+    plotIndex.get(plotSelect.value).show();
+  });
+  plotSelect.value = getDefaultPlot();
+  // Force the event to occur once at the start.
+  plotSelect.dispatchEvent(new Event('input'));
+});
diff --git a/frc971/analysis/web_plotter.sh b/frc971/analysis/web_plotter.sh
new file mode 100755
index 0000000..b284431
--- /dev/null
+++ b/frc971/analysis/web_plotter.sh
@@ -0,0 +1,4 @@
+# This script provides basic plotting of a logfile.
+# Basic usage:
+# $ bazel run -c opt //frc971/analysis:web_plotter -- --node node_name /path/to/logfile
+./aos/network/log_web_proxy_main --data_dir=frc971/analysis $@
diff --git a/frc971/wpilib/BUILD b/frc971/wpilib/BUILD
index e7c52bf..087c413 100644
--- a/frc971/wpilib/BUILD
+++ b/frc971/wpilib/BUILD
@@ -1,6 +1,7 @@
 package(default_visibility = ["//visibility:public"])
 
-load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
+load("@npm_bazel_typescript//:defs.bzl", "ts_library")
+load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library", "flatbuffer_ts_library")
 load("//aos:config.bzl", "aos_config")
 
 flatbuffer_cc_library(
@@ -286,6 +287,15 @@
     target_compatible_with = ["@platforms//os:linux"],
 )
 
+flatbuffer_ts_library(
+    name = "imu_batch_ts_fbs",
+    srcs = ["imu_batch.fbs"],
+    includes = [
+        ":imu_fbs_includes",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+)
+
 cc_library(
     name = "ADIS16470",
     srcs = [
@@ -422,3 +432,27 @@
         "@com_github_google_glog//:glog",
     ],
 )
+
+ts_library(
+    name = "imu_plot_utils",
+    srcs = ["imu_plot_utils.ts"],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":imu_batch_ts_fbs",
+        "//aos:configuration_ts_fbs",
+        "//aos/network/www:aos_plotter",
+        "//aos/network/www:reflection_ts",
+        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+    ],
+)
+
+ts_library(
+    name = "imu_plotter",
+    srcs = ["imu_plotter.ts"],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":imu_plot_utils",
+        "//aos/network/www:aos_plotter",
+        "//aos/network/www:proxy",
+    ],
+)
diff --git a/frc971/wpilib/imu_plot_utils.ts b/frc971/wpilib/imu_plot_utils.ts
new file mode 100644
index 0000000..4e6c371
--- /dev/null
+++ b/frc971/wpilib/imu_plot_utils.ts
@@ -0,0 +1,34 @@
+// This script provides a basic utility for de-batching the IMUValues
+// message. See imu_plotter.ts for usage.
+import * as configuration from 'org_frc971/aos/configuration_generated';
+import * as imu from 'org_frc971/frc971/wpilib/imu_batch_generated';
+import {MessageHandler, TimestampedMessage} from 'org_frc971/aos/network/www/aos_plotter';
+import {Table} from 'org_frc971/aos/network/www/reflection';
+import {ByteBuffer} from 'org_frc971/external/com_github_google_flatbuffers/ts/byte-buffer';
+
+import Schema = configuration.reflection.Schema;
+import IMUValuesBatch = imu.frc971.IMUValuesBatch;
+import IMUValues = imu.frc971.IMUValues;
+
+export class ImuMessageHandler extends MessageHandler {
+  constructor(private readonly schema: Schema) {
+    super(schema);
+  }
+  addMessage(data: Uint8Array, time: number): void {
+    const batch = IMUValuesBatch.getRootAsIMUValuesBatch(
+        new ByteBuffer(data) as unknown as flatbuffers.ByteBuffer);
+    for (let ii = 0; ii < batch.readingsLength(); ++ii) {
+      const message = batch.readings(ii);
+      const table = Table.getNamedTable(
+          message.bb as unknown as ByteBuffer, this.schema, 'frc971.IMUValues',
+          message.bb_pos);
+      if (this.parser.readScalar(table, "monotonic_timestamp_ns") == null) {
+        console.log('Ignoring unpopulated IMU values: ');
+        console.log(this.parser.toObject(table));
+        continue;
+      }
+      this.messages.push(new TimestampedMessage(
+          table, message.monotonicTimestampNs().toFloat64() * 1e-9));
+    }
+  }
+}
diff --git a/frc971/wpilib/imu_plotter.ts b/frc971/wpilib/imu_plotter.ts
new file mode 100644
index 0000000..af23ed9
--- /dev/null
+++ b/frc971/wpilib/imu_plotter.ts
@@ -0,0 +1,80 @@
+// Provides a basic plot for debugging IMU-related issues on a robot.
+import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
+import {ImuMessageHandler} from 'org_frc971/frc971/wpilib/imu_plot_utils';
+import * as proxy from 'org_frc971/aos/network/www/proxy';
+
+import Connection = proxy.Connection;
+
+export function plotImu(conn: Connection, element: Element): void {
+  const width = 900;
+  const height = 400;
+  const aosPlotter = new AosPlotter(conn);
+
+  const accelPlot = aosPlotter.addPlot(element, [0, 0], [width, height]);
+  accelPlot.plot.getAxisLabels().setTitle('Accelerometer Readings');
+  accelPlot.plot.getAxisLabels().setYLabel('Acceleration (g)');
+  accelPlot.plot.getAxisLabels().setXLabel('Monotonic Reading Time (sec)');
+
+  const drivetrainStatus = aosPlotter.addMessageSource(
+      '/drivetrain', 'frc971.control_loops.drivetrain.Status');
+
+  const imu = aosPlotter.addRawMessageSource(
+      '/drivetrain', 'frc971.IMUValuesBatch',
+      new ImuMessageHandler(conn.getSchema('frc971.IMUValuesBatch')));
+
+  const accelX = accelPlot.addMessageLine(imu, ['accelerometer_x']);
+  accelX.setColor([1, 0, 0]);
+  const accelY = accelPlot.addMessageLine(imu, ['accelerometer_y']);
+  accelY.setColor([0, 1, 0]);
+  const accelZ = accelPlot.addMessageLine(imu, ['accelerometer_z']);
+  accelZ.setColor([0, 0, 1]);
+
+  const gyroPlot = aosPlotter.addPlot(element, [0, height], [width, height]);
+  gyroPlot.plot.getAxisLabels().setTitle('Gyro Readings');
+  gyroPlot.plot.getAxisLabels().setYLabel('Angular Velocity (rad / sec)');
+  gyroPlot.plot.getAxisLabels().setXLabel('Monotonic Reading Time (sec)');
+
+  const gyroZeroX =
+      gyroPlot.addMessageLine(drivetrainStatus, ['zeroing', 'gyro_x_average']);
+  gyroZeroX.setColor([1, 0, 0]);
+  gyroZeroX.setPointSize(0);
+  gyroZeroX.setLabel('Gyro X Zero');
+  const gyroZeroY =
+      gyroPlot.addMessageLine(drivetrainStatus, ['zeroing', 'gyro_y_average']);
+  gyroZeroY.setColor([0, 1, 0]);
+  gyroZeroY.setPointSize(0);
+  gyroZeroY.setLabel('Gyro Y Zero');
+  const gyroZeroZ =
+      gyroPlot.addMessageLine(drivetrainStatus, ['zeroing', 'gyro_z_average']);
+  gyroZeroZ.setColor([0, 0, 1]);
+  gyroZeroZ.setPointSize(0);
+  gyroZeroZ.setLabel('Gyro Z Zero');
+
+  const gyroX = gyroPlot.addMessageLine(imu, ['gyro_x']);
+  gyroX.setColor([1, 0, 0]);
+  const gyroY = gyroPlot.addMessageLine(imu, ['gyro_y']);
+  gyroY.setColor([0, 1, 0]);
+  const gyroZ = gyroPlot.addMessageLine(imu, ['gyro_z']);
+  gyroZ.setColor([0, 0, 1]);
+
+  const tempPlot = aosPlotter.addPlot(element, [0, height * 2], [width, height / 2]);
+  tempPlot.plot.getAxisLabels().setTitle('IMU Temperature');
+  tempPlot.plot.getAxisLabels().setYLabel('Temperature (deg C)');
+  tempPlot.plot.getAxisLabels().setXLabel('Monotonic Reading Time (sec)');
+
+  tempPlot.addMessageLine(imu, ['temperature']);
+
+  const statePlot = aosPlotter.addPlot(element, [0, height * 2.5], [width, height / 2]);
+  statePlot.plot.getAxisLabels().setTitle('IMU State');
+  statePlot.plot.getAxisLabels().setXLabel('Monotonic Sent Time (sec)');
+  statePlot.plot.setDefaultYRange([-0.1, 1.1]);
+
+  const zeroedLine =
+      statePlot.addMessageLine(drivetrainStatus, ['zeroing', 'zeroed']);
+  zeroedLine.setColor([1, 0, 0]);
+  zeroedLine.setDrawLine(false);
+  const faultedLine =
+  statePlot.addMessageLine(drivetrainStatus, ['zeroing', 'faulted']);
+  faultedLine.setColor([0, 1, 0]);
+  faultedLine.setPointSize(0);
+}