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/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);
+}