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) {