Add basic plotter for WebGL

There're still a bunch of TODOs. Will address those when we actually
have a need for this.

Change-Id: I137390d384967ffb618e0ecda37503e1e0df8301
diff --git a/build_tests/BUILD b/build_tests/BUILD
index 076e18c..a4c5829 100644
--- a/build_tests/BUILD
+++ b/build_tests/BUILD
@@ -26,6 +26,24 @@
     ],
 )
 
+emcc_binary(
+    name = "plotter.html",
+    srcs = ["webgl2_plot_test.cc"],
+    html_shell = "minimal_shell.html",
+    linkopts = [
+        "-s",
+        "USE_WEBGL2=1",
+        "-s",
+        "FULL_ES3=1",
+        "-s",
+        "TOTAL_MEMORY=" + repr(256 * 1024 * 1024),
+    ],
+    deps = [
+        "//frc971/analysis/plotting:webgl2_animator",
+        "//frc971/analysis/plotting:webgl2_plotter",
+    ],
+)
+
 cc_test(
     name = "gflags_build_test",
     size = "small",
diff --git a/build_tests/minimal_shell.html b/build_tests/minimal_shell.html
index 6158571..c9d2b86 100644
--- a/build_tests/minimal_shell.html
+++ b/build_tests/minimal_shell.html
@@ -56,7 +56,21 @@
     </div>
     <!--The width and height values in the canvas specify the pixel width/height for the WebGL canvas.
         The actual on-screen size is controlled by the stylesheet.-->
-    <canvas class="emscripten" id="canvas" oncontextmenu="event.preventDefault()" width=3000 height=2000></canvas>
+    <canvas class="emscripten" id="canvas" oncontextmenu="event.preventDefault()" width=1200 height=600></canvas>
+
+    <div>
+      General help information:<br>
+      <ul>
+        <li>Double-click to reset zoom.</li>
+        <li>Left-click to print mouse position (within plot) to console.</li>
+        <li>Ctrl-Z to undo zoom actions.</li>
+        <li>Right-click and drag to pan.</li>
+        <li>Left-click and drag zooms to the dragged area. If you press Escape while dragging, it will cancel the zoom.</li>
+        <li>Scroll up/down will zoom in and out.</li>
+        <li>Holding down "x" and "y" will restrict any
+            movement to the x and y axes respectively.</li>
+      </ul>
+    </div>
 
     <script type='text/javascript'>
       var statusElement = document.getElementById('status');
diff --git a/build_tests/webgl2_plot_test.cc b/build_tests/webgl2_plot_test.cc
new file mode 100644
index 0000000..2baefa9
--- /dev/null
+++ b/build_tests/webgl2_plot_test.cc
@@ -0,0 +1,35 @@
+#include <emscripten/emscripten.h>
+#include <emscripten/html5.h>
+
+#include <iostream>
+
+#include "frc971/analysis/plotting/webgl2_plotter.h"
+#include "frc971/analysis/plotting/webgl2_animator.h"
+
+float rand1() {
+  return static_cast<float>(rand()) / RAND_MAX;
+}
+
+int main() {
+  // Note that the animation_state must last until Redraw stops being called,
+  // which we cannot provide any bound on. As such, we don't currently destroy
+  // the memory until the webpage is closed.
+  frc971::plotting::Animator *animation_state =
+      new frc971::plotting::Animator("#canvas");
+  // Generate a bunch of lines with random y-values and evenly spaced x-values,
+  // such that each line takes up a set amount of space in the y-space. If
+  // that's unclear, then try running this and seeing what it looks like.
+  constexpr size_t kNLines = 30;
+  for (int jj = 0; jj < kNLines; ++jj) {
+    frc971::plotting::Line *line = animation_state->plotter()->AddLine();
+    // Randomly generate a color to use; each of r/g/b are between 0 and 1.
+    line->SetColor({.r = rand1(), .g = rand1(), .b = rand1()});
+    std::vector<Eigen::Vector2d> points;
+    constexpr size_t kNPoints = 100000;
+    for (int ii = 0; ii < kNPoints; ++ii) {
+      const float x = static_cast<float>(ii) / kNPoints;
+      points.emplace_back(x, std::sin(x + jj));
+    }
+    line->SetPoints(points);
+  }
+}
diff --git a/frc971/analysis/plotting/BUILD b/frc971/analysis/plotting/BUILD
new file mode 100644
index 0000000..eb817ba
--- /dev/null
+++ b/frc971/analysis/plotting/BUILD
@@ -0,0 +1,30 @@
+cc_library(
+    name = "webgl2_plotter",
+    srcs = ["webgl2_plotter.cc"],
+    hdrs = ["webgl2_plotter.h"],
+    linkopts = [
+        "-s",
+        "USE_WEBGL2=1",
+        "-s",
+        "FULL_ES3=1",
+    ],
+    restricted_to = ["//tools:web"],
+    visibility = ["//visibility:public"],
+    deps = ["@org_tuxfamily_eigen//:eigen"],
+)
+
+cc_library(
+    name = "webgl2_animator",
+    srcs = ["webgl2_animator.cc"],
+    hdrs = ["webgl2_animator.h"],
+    linkopts = [
+        "-s",
+        "USE_WEBGL2=1",
+    ],
+    restricted_to = ["//tools:web"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":webgl2_plotter",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
diff --git a/frc971/analysis/plotting/webgl2_animator.cc b/frc971/analysis/plotting/webgl2_animator.cc
new file mode 100644
index 0000000..148245e
--- /dev/null
+++ b/frc971/analysis/plotting/webgl2_animator.cc
@@ -0,0 +1,283 @@
+#include "frc971/analysis/plotting/webgl2_animator.h"
+
+namespace frc971 {
+namespace plotting {
+
+namespace {
+struct Button {
+  bool IsTransition(const EmscriptenMouseEvent &mouse_event) {
+    return mouse_event.button == transition_number_;
+  }
+  bool IsPressed(const EmscriptenMouseEvent &mouse_event) {
+    return mouse_event.buttons & (1 << pressed_index_);
+  }
+  const size_t transition_number_;
+  const size_t pressed_index_;
+};
+constexpr Button kLeftButton() { return {0, 0}; }
+//constexpr Button kMiddleButton() { return {1, 2}; }
+constexpr Button kRightButton() { return {2, 1}; }
+
+constexpr Button kPanButton() { return kLeftButton(); }
+constexpr Button kZoomButton() { return kRightButton(); }
+}  // namespace
+
+Animator::Animator(const char *canvas_target) : plotter_(canvas_target) {
+  // TODO(james): Write a proper CHECK macro or figure out how to import glog.
+  // Importing glog is a bit of a pain, since it seems to assume all sorts of
+  // things that don't really apply on the web.
+  assert(EMSCRIPTEN_RESULT_SUCCESS ==
+         emscripten_get_canvas_element_size(canvas_target, &canvas_width_,
+                                            &canvas_height_));
+  assert(EMSCRIPTEN_RESULT_SUCCESS ==
+         emscripten_set_mousemove_callback("#canvas", this, true,
+                                           &Animator::MouseCallback));
+  assert(EMSCRIPTEN_RESULT_SUCCESS ==
+         emscripten_set_click_callback("#canvas", this, true,
+                                       &Animator::MouseCallback));
+  assert(EMSCRIPTEN_RESULT_SUCCESS ==
+         emscripten_set_mousedown_callback("#canvas", this, true,
+                                           &Animator::MouseCallback));
+  assert(EMSCRIPTEN_RESULT_SUCCESS ==
+         emscripten_set_mouseup_callback("#canvas", this, true,
+                                         &Animator::MouseCallback));
+  assert(EMSCRIPTEN_RESULT_SUCCESS ==
+         emscripten_set_mouseleave_callback("#canvas", this, true,
+                                            &Animator::MouseCallback));
+  assert(EMSCRIPTEN_RESULT_SUCCESS ==
+         emscripten_set_mouseenter_callback("#canvas", this, true,
+                                            &Animator::MouseCallback));
+  assert(EMSCRIPTEN_RESULT_SUCCESS ==
+         emscripten_set_dblclick_callback("#canvas", this, true,
+                                          &Animator::MouseCallback));
+  assert(EMSCRIPTEN_RESULT_SUCCESS ==
+         emscripten_set_wheel_callback("#canvas", this, true,
+                                       &Animator::WheelCallback));
+  assert(EMSCRIPTEN_RESULT_SUCCESS ==
+         emscripten_set_keypress_callback("#document", this, true,
+                                          &Animator::KeyboardCallback));
+  assert(EMSCRIPTEN_RESULT_SUCCESS ==
+         emscripten_set_keydown_callback("#document", this, true,
+                                         &Animator::KeyboardCallback));
+  assert(EMSCRIPTEN_RESULT_SUCCESS ==
+         emscripten_set_keyup_callback("#document", this, true,
+                                       &Animator::KeyboardCallback));
+  emscripten_request_animation_frame_loop(&Animator::Redraw, this);
+}
+
+Eigen::Vector2d Animator::MouseCanvasLocation(
+    const EmscriptenMouseEvent &mouse_event) {
+  return {mouse_event.canvasX * 2.0 / canvas_width_ - 1.0,
+          -mouse_event.canvasY * 2.0 / canvas_height_ + 1.0};
+}
+
+Eigen::Vector2d Animator::CanvasToPlotLocation(
+    const Eigen::Vector2d &canvas_loc) {
+  return (canvas_loc - plotter_.GetOffset()).cwiseQuotient(plotter_.GetScale());
+}
+
+void Animator::PrintZoom() {
+  const Eigen::Vector2d upper_right = CanvasToPlotLocation({1.0, 1.0});
+  const Eigen::Vector2d lower_left = CanvasToPlotLocation({-1.0, -1.0});
+  printf("X range is [%f, %f]; Y range is [%f, %f]\n", lower_left.x(),
+         upper_right.x(), lower_left.y(), upper_right.y());
+}
+
+void Animator::PrintPosition(const EmscriptenMouseEvent &mouse_event) {
+  const Eigen::Vector2d mouse_pos =
+      CanvasToPlotLocation(MouseCanvasLocation(mouse_event));
+  printf("Mouse position: (%f, %f)\n", mouse_pos.x(), mouse_pos.y());
+}
+
+void Animator::HandleMouseUp(const EmscriptenMouseEvent &mouse_event) {
+  if (!kZoomButton().IsTransition(mouse_event)) {
+    return;
+  }
+  // We aborted the zoom early for some reason and so shouldn't execute on it:
+  if (!doing_rectangle_zoom_) {
+    return;
+  }
+  const Eigen::Vector2d mouse_up_location = MouseCanvasLocation(mouse_event);
+  doing_rectangle_zoom_ = false;
+  plotter_.ClearZoomRectangle();
+  // The user probably didn't mean to zoom on that click...
+  if ((mouse_up_location - mouse_down_location_).cwiseAbs().minCoeff() < 1e-3) {
+    return;
+  }
+  const Eigen::Vector2d mouse_up_plot_location =
+      CanvasToPlotLocation(mouse_up_location);
+  const Eigen::Vector2d mouse_down_plot_location =
+      CanvasToPlotLocation(mouse_down_location_);
+  SetZoomCorners(mouse_down_plot_location, mouse_up_plot_location);
+}
+
+void Animator::HandleMouseDown(const EmscriptenMouseEvent &mouse_event) {
+  if (kZoomButton().IsTransition(mouse_event)) {
+    mouse_down_location_ = MouseCanvasLocation(mouse_event);
+    doing_rectangle_zoom_ = true;
+  } else if (kPanButton().IsTransition(mouse_event)) {
+    last_pan_mouse_location_ = MouseCanvasLocation(mouse_event);
+  }
+}
+
+void Animator::HandleMouseMove(const EmscriptenMouseEvent &mouse_event) {
+  const Eigen::Vector2d mouse_location = MouseCanvasLocation(mouse_event);
+  if (kPanButton().IsPressed(mouse_event)) {
+    SetFilteredZoom(plotter_.GetScale(), plotter_.GetOffset() + mouse_location -
+                                            last_pan_mouse_location_);
+    last_pan_mouse_location_ = mouse_location;
+  }
+  if (doing_rectangle_zoom_) {
+    Eigen::Vector2d c1 = CanvasToPlotLocation(mouse_down_location_);
+    Eigen::Vector2d c2 = CanvasToPlotLocation(mouse_location);
+    const Eigen::Vector2d upper_right = CanvasToPlotLocation({1.0, 1.0});
+    const Eigen::Vector2d bottom_left = CanvasToPlotLocation({-1.0, -1.0});
+    if (x_pressed_ && !y_pressed_) {
+      c1.y() = upper_right.y();
+      c2.y() = bottom_left.y();
+    }
+    if (y_pressed_ && !x_pressed_) {
+      c1.x() = upper_right.x();
+      c2.x() = bottom_left.x();
+    }
+    plotter_.SetZoomRectangle(c1, c2);
+  }
+}
+
+void Animator::HandleMouseEnter(const EmscriptenMouseEvent &mouse_event) {
+  // If the zoom button is unclicked and we were zooming, instantly finish the
+  // rectangle zoom.
+  if (doing_rectangle_zoom_ && !kZoomButton().IsPressed(mouse_event)) {
+    plotter_.ClearZoomRectangle();
+    doing_rectangle_zoom_ = false;
+    // Round the current mouse location to the nearest of the four corners.
+    // This is to ensure that a zoom that occurs when the use goes of the edge
+    // of the screen actually goes right up to the edge of the canvas.
+    // Technically, cwiseSign will return zero if you get the mouse enter
+    // event to trigger with the mouse at the center of the screen, but that
+    // seems unlikely.
+    const Eigen::Vector2d canvas_corner =
+        MouseCanvasLocation(mouse_event).cwiseSign();
+    SetZoomCorners(CanvasToPlotLocation(mouse_down_location_),
+                   CanvasToPlotLocation(canvas_corner));
+  }
+}
+
+void Animator::SetZoomCorners(const Eigen::Vector2d &c1,
+                              const Eigen::Vector2d &c2) {
+  const Eigen::Vector2d scale = ((c2 - c1).cwiseAbs() / 2.0).cwiseInverse();
+  const Eigen::Vector2d offset =
+      Eigen::Vector2d::Ones() - scale.cwiseProduct(c2.cwiseMax(c1));
+  SetFilteredZoom(scale, offset);
+}
+
+void Animator::SetFilteredZoom(Eigen::Vector2d scale, Eigen::Vector2d offset) {
+  if (!x_pressed_ && y_pressed_) {
+    scale.x() = plotter_.GetScale().x();
+    offset.x() = plotter_.GetOffset().x();
+  }
+  if (!y_pressed_ && x_pressed_) {
+    scale.y() = plotter_.GetScale().y();
+    offset.y() = plotter_.GetOffset().y();
+  }
+  plotter_.RecordState();
+  plotter_.SetScale(scale);
+  plotter_.SetOffset(offset);
+  PrintZoom();
+}
+
+void Animator::ResetView() {
+  SetZoomCorners(plotter_.MinValues(), plotter_.MaxValues());
+}
+
+int Animator::Redraw(double time_ms, void *data) {
+  Animator *state = reinterpret_cast<Animator *>(data);
+  state->plotter_.Redraw();
+  return 1;
+}
+
+int Animator::KeyboardCallback(int event_type,
+                               const EmscriptenKeyboardEvent *key_event,
+                               void *data) {
+  Animator *state = reinterpret_cast<Animator *>(data);
+  const bool key_is_pressed = event_type == EMSCRIPTEN_EVENT_KEYDOWN;
+  if (strncmp(key_event->key, "x", 2) == 0) {
+    state->x_pressed_ = key_is_pressed;
+    return true;
+  } else if (strncmp(key_event->key, "y", 2) == 0) {
+    state->y_pressed_ = key_is_pressed;
+    return true;
+  } else if (strncmp(key_event->key, "z", 2) == 0 && key_event->ctrlKey) {
+    if (event_type == EMSCRIPTEN_EVENT_KEYUP) {
+      state->plotter_.Undo();
+      state->PrintZoom();
+    }
+    return true;
+  } else if (strncmp(key_event->key, "Escape", 7) == 0) {
+    state->doing_rectangle_zoom_ = false;
+    state->plotter_.ClearZoomRectangle();
+    return true;
+  }
+  return false;
+}
+
+int Animator::WheelCallback(int event_type,
+                            const EmscriptenWheelEvent *wheel_event,
+                            void *data) {
+  Animator *state = reinterpret_cast<Animator *>(data);
+  assert(event_type == EMSCRIPTEN_EVENT_WHEEL);
+  if (wheel_event->deltaMode == DOM_DELTA_PIXEL) {
+    const Eigen::Vector2d canvas_pos =
+        state->MouseCanvasLocation(wheel_event->mouse);
+    constexpr double kWheelTuningScalar = 3.0;
+    const double zoom =
+        -kWheelTuningScalar * wheel_event->deltaY / state->canvas_height_;
+    double zoom_scalar = 1.0 + std::abs(zoom);
+    if (zoom < 0) {
+      zoom_scalar = 1.0 / zoom_scalar;
+    }
+    const Eigen::Vector2d scale = state->plotter_.GetScale() * zoom_scalar;
+    const Eigen::Vector2d offset = (1.0 - zoom_scalar) * canvas_pos +
+                                   zoom_scalar * state->plotter_.GetOffset();
+    state->SetFilteredZoom(scale, offset);
+    return true;
+  }
+  return false;
+}
+
+int Animator::MouseCallback(int event_type,
+                            const EmscriptenMouseEvent *mouse_event,
+                            void *data) {
+  Animator *state = reinterpret_cast<Animator *>(data);
+  switch (event_type) {
+    case EMSCRIPTEN_EVENT_CLICK:
+      state->PrintZoom();
+      state->PrintPosition(*mouse_event);
+      return true;
+      break;
+    case EMSCRIPTEN_EVENT_DBLCLICK:
+      state->ResetView();
+      return true;
+      break;
+    case EMSCRIPTEN_EVENT_MOUSEDOWN:
+      state->HandleMouseDown(*mouse_event);
+      return true;
+      break;
+    case EMSCRIPTEN_EVENT_MOUSEUP:
+      state->HandleMouseUp(*mouse_event);
+      return true;
+      break;
+    case EMSCRIPTEN_EVENT_MOUSEMOVE:
+      state->HandleMouseMove(*mouse_event);
+      return true;
+      break;
+    case EMSCRIPTEN_EVENT_MOUSEENTER:
+      state->HandleMouseEnter(*mouse_event);
+      return true;
+      break;
+  }
+  return false;
+}
+
+}  // namespace plotting
+}  // namespace frc971
diff --git a/frc971/analysis/plotting/webgl2_animator.h b/frc971/analysis/plotting/webgl2_animator.h
new file mode 100644
index 0000000..4674bc0
--- /dev/null
+++ b/frc971/analysis/plotting/webgl2_animator.h
@@ -0,0 +1,79 @@
+#ifndef FRC971_ANALYSIS_PLOTTING_WEBGL2_ANIMATOR_H_
+#define FRC971_ANALYSIS_PLOTTING_WEBGL2_ANIMATOR_H_
+
+#include <Eigen/Dense>
+#include <emscripten/emscripten.h>
+#include <emscripten/html5.h>
+
+#include "frc971/analysis/plotting/webgl2_plotter.h"
+
+namespace frc971 {
+namespace plotting {
+
+// TODO(james): Write some tests for this class. It shouldn't be too hard to
+// abstract out all the direct emscripten calls. Mostly it's just some
+// initialization at the moment.
+class Animator {
+ public:
+  Animator(const char *canvas_target);
+
+  Plotter *plotter() { return &plotter_; }
+
+ private:
+  Eigen::Vector2d MouseCanvasLocation(const EmscriptenMouseEvent &mouse_event);
+
+  Eigen::Vector2d CanvasToPlotLocation(const Eigen::Vector2d &canvas_loc);
+
+  void PrintZoom();
+
+  void PrintPosition(const EmscriptenMouseEvent &mouse_event);
+
+  void HandleMouseUp(const EmscriptenMouseEvent &mouse_event);
+
+  void HandleMouseDown(const EmscriptenMouseEvent &mouse_event);
+
+  void HandleMouseMove(const EmscriptenMouseEvent &mouse_event);
+
+  void HandleMouseEnter(const EmscriptenMouseEvent &mouse_event);
+
+  void SetZoomCorners(const Eigen::Vector2d &c1, const Eigen::Vector2d &c2);
+
+  void SetFilteredZoom(Eigen::Vector2d scale, Eigen::Vector2d offset);
+
+  void ResetView();
+
+  static int Redraw(double time_ms, void *data);
+  static int KeyboardCallback(int event_type,
+                              const EmscriptenKeyboardEvent *key_event,
+                              void *data);
+  static int WheelCallback(int event_type,
+                           const EmscriptenWheelEvent *wheel_event, void *data);
+  static int MouseCallback(int event_type,
+                           const EmscriptenMouseEvent *mouse_event, void *data);
+
+  int canvas_width_ = 0.0;
+  int canvas_height_ = 0.0;
+
+  // Location, in canvas coordinates of the last left click mouse-down event.
+  Eigen::Vector2d mouse_down_location_{0, 0};
+
+  // True if the user is currently dragging their mouse to zoom to a rectangle.
+  // This is used to (a) determine whether we should subsequently execute the
+  // zoom when the user releases the mouse and (b) to know when to draw a
+  // rectangle indicating where the user is zooming to.
+  bool doing_rectangle_zoom_ = false;
+
+  // The last location of the mouse when panning, so that we can calculate
+  // exactly how much the mouse has moved since the last mouse-move callback.
+  Eigen::Vector2d last_pan_mouse_location_{0, 0};
+
+  // Whether the "x" or "y" key is currently pressed on the keyboard.
+  bool x_pressed_ = false;
+  bool y_pressed_ = false;
+
+  WebglCanvasPlotter plotter_;
+};
+
+}  // namespace plotting
+}  // namespace frc971
+#endif  // FRC971_ANALYSIS_PLOTTING_WEBGL2_ANIMATOR_H_
diff --git a/frc971/analysis/plotting/webgl2_plotter.cc b/frc971/analysis/plotting/webgl2_plotter.cc
new file mode 100644
index 0000000..b10a1de
--- /dev/null
+++ b/frc971/analysis/plotting/webgl2_plotter.cc
@@ -0,0 +1,277 @@
+#include "frc971/analysis/plotting/webgl2_plotter.h"
+
+#include <assert.h>
+#include <stdlib.h>
+
+#include <iostream>
+
+#include <emscripten/emscripten.h>
+#include <emscripten/html5.h>
+
+namespace frc971 {
+namespace plotting {
+
+namespace {
+// Shader and program construction taken from examples at
+// https://github.com/emscripten-core/emscripten/blob/incoming/tests/webgl2_draw_packed_triangle.c
+GLuint compile_shader(GLenum shaderType, const char *src) {
+  GLuint shader = glCreateShader(shaderType);
+  glShaderSource(shader, 1, &src, nullptr);
+  glCompileShader(shader);
+
+  GLint isCompiled = 0;
+  glGetShaderiv(shader, GL_COMPILE_STATUS, &isCompiled);
+  if (!isCompiled) {
+    GLint maxLength = 0;
+    glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &maxLength);
+    char *buf = (char *)malloc(maxLength + 1);
+    glGetShaderInfoLog(shader, maxLength, &maxLength, buf);
+    printf("%s\n", buf);
+    free(buf);
+    return 0;
+  }
+
+  return shader;
+}
+
+GLuint create_program(GLuint vertexShader, GLuint fragmentShader,
+                      GLuint attribute_location) {
+  GLuint program = glCreateProgram();
+  glAttachShader(program, vertexShader);
+  glAttachShader(program, fragmentShader);
+  glBindAttribLocation(program, attribute_location, "apos");
+  glLinkProgram(program);
+  return program;
+}
+
+GLuint CreateLineProgram(GLuint attribute_location) {
+  // Create a shader program which will take in:
+  // -A series of points to plot
+  // -Scale/offset parameters for choosing how to zoom/pan
+  // -Point size/color information
+  //
+  // The vertex shader then takes in the series of points (apos) and
+  // transforms the points by the scale/offset to determine their on-screen
+  // position. We aren't doing any funny with 3D or perspective, so we leave
+  // the z and w components untouched.
+  //
+  // We set the color of the line in the fragment shader.
+  const char vertex_shader[] =
+    "#version 100\n"
+    "attribute vec2 apos;"
+    "uniform vec2 scale;"
+    "uniform vec2 offset;"
+    "uniform float point_size;"
+    "void main() {"
+      "gl_Position.xy = apos.xy * scale.xy + offset.xy;"
+      "gl_Position.z = 0.0;"
+      "gl_Position.w = 1.0;"
+      "gl_PointSize = point_size;"
+    "}";
+  GLuint vs = compile_shader(GL_VERTEX_SHADER, vertex_shader);
+
+  const char fragment_shader[] =
+    "#version 100\n"
+    "precision lowp float;"
+    "uniform vec4 color;"
+    "void main() {"
+      "gl_FragColor = color;"
+    "}";
+  GLuint fs = compile_shader(GL_FRAGMENT_SHADER, fragment_shader);
+  return create_program(vs, fs, attribute_location);
+}
+
+const Eigen::Vector2d Vector2dInfinity() {
+  return Eigen::Vector2d::Ones() * std::numeric_limits<double>::infinity();
+}
+
+}  // namespace
+
+class WebglLine : public Line {
+ public:
+  WebglLine(GLuint program, size_t buffer_size = 1000000)
+      : color_uniform_location_(glGetUniformLocation(program, "color")),
+        point_size_uniform_location_(
+            glGetUniformLocation(program, "point_size")),
+        line_size_(0) {}
+  virtual ~WebglLine() {}
+  void SetPoints(const std::vector<Eigen::Vector2d> &pts) override {
+    updated_ = true;
+    max_values_ = -Vector2dInfinity();
+    min_values_ = Vector2dInfinity();
+    line_size_ = 0;
+    buffer_.clear();
+    for (const auto &pt : pts) {
+      buffer_.push_back(pt.x());
+      buffer_.push_back(pt.y());
+      max_values_ = max_values_.cwiseMax(pt);
+      min_values_ = min_values_.cwiseMin(pt);
+      ++line_size_;
+    }
+  }
+  void Draw() override {
+    updated_ = false;
+    if (buffer_.empty()) {
+      return;
+    }
+    // TODO(james): Flushing and rewriting the buffer on every line draw seems
+    // like it should be inefficient, but in practice it seems to actually be
+    // fine for the amounts of data that we are dealing with (i.e., doing a few
+    // tens of MB of copies at the most is not actually that expensive).
+    glBufferData(GL_ARRAY_BUFFER, buffer_.size() * sizeof(float),
+                 buffer_.data(), GL_STATIC_DRAW);
+    glUniform4f(color_uniform_location_, color_.r, color_.g, color_.b, 1.0);
+    glUniform1f(point_size_uniform_location_, point_size_);
+    if (point_size_ != 0) {
+      glDrawArrays(GL_POINTS, 0, line_size_);
+    }
+    if (line_width_ != 0) {
+      glDrawArrays(GL_LINE_STRIP, 0, line_size_);
+    }
+    assert(GL_NO_ERROR == glGetError() && "glDrawArray failed");
+  }
+  void SetColor(const Color &color) override {
+    updated_ = true;
+    color_ = color;
+  }
+
+  Eigen::Vector2d MaxValues() const override { return max_values_; }
+  Eigen::Vector2d MinValues() const override { return min_values_; }
+
+  void SetLineWidth(const float width) override {
+    updated_ = true;
+    line_width_ = width;
+  }
+  void SetPointSize(const float point_size) override {
+    updated_ = true;
+    point_size_ = point_size;
+  }
+
+  bool HasUpdate() override { return updated_; }
+
+ private:
+  const GLuint color_uniform_location_;
+  const GLuint point_size_uniform_location_;
+  std::vector<float> buffer_;
+  size_t line_size_;
+  Color color_;
+  Eigen::Vector2d max_values_ = -Vector2dInfinity();
+  Eigen::Vector2d min_values_ = Vector2dInfinity();
+  float line_width_ = 1.0;
+  float point_size_ = 3.0;
+  bool updated_ = true;
+};
+
+WebglCanvasPlotter::WebglCanvasPlotter(const std::string &canvas_id,
+                                       GLuint attribute_location)  {
+  EmscriptenWebGLContextAttributes attr;
+  emscripten_webgl_init_context_attributes(&attr);
+  assert(attr.antialias && "Antialiasing should be enabled by default.");
+  attr.majorVersion = 2;
+  EMSCRIPTEN_WEBGL_CONTEXT_HANDLE ctx = emscripten_webgl_create_context("#canvas", &attr);
+  assert(ctx && "Failed to create WebGL2 context");
+  emscripten_webgl_make_context_current(ctx);
+
+  program_ = CreateLineProgram(attribute_location);
+  scale_uniform_location_ = glGetUniformLocation(program_, "scale");
+  offset_uniform_location_ = glGetUniformLocation(program_, "offset");
+
+  glGenBuffers(1, &gl_buffer_);
+  glUseProgram(program_);
+  glBindBuffer(GL_ARRAY_BUFFER, gl_buffer_);
+  glVertexAttribPointer(attribute_location, 2, GL_FLOAT, GL_FALSE, 8, 0);
+  glEnableVertexAttribArray(attribute_location);
+
+  zoom_rectangle_ = std::make_unique<WebglLine>(program_);
+  zoom_rectangle_->SetColor({.r = 1.0, .g = 1.0, .b = 1.0});
+  zoom_rectangle_->SetLineWidth(2.0);
+  zoom_rectangle_->SetPointSize(0.0);
+}
+
+Line *WebglCanvasPlotter::AddLine() {
+  lines_.push_back(std::make_unique<WebglLine>(program_));
+  return lines_.back().get();
+}
+void WebglCanvasPlotter::Undo() {
+  if (old_scales_.empty() || old_offsets_.empty()) {
+    return;
+  }
+  scale_ = old_scales_.back();
+  old_scales_.pop_back();
+  offset_ = old_offsets_.back();
+  old_offsets_.pop_back();
+}
+
+void WebglCanvasPlotter::RecordState() {
+  old_scales_.push_back(scale_);
+  old_offsets_ .push_back(offset_);
+}
+
+void WebglCanvasPlotter::SetScale(const Eigen::Vector2d &scale) {
+  scale_ = scale;
+}
+
+Eigen::Vector2d WebglCanvasPlotter::GetScale() const {
+  return scale_;
+}
+
+void WebglCanvasPlotter::SetOffset(const Eigen::Vector2d &offset) {
+  offset_ = offset;
+}
+
+Eigen::Vector2d WebglCanvasPlotter::GetOffset() const {
+  return offset_;
+}
+
+void WebglCanvasPlotter::Redraw() {
+  const bool scaling_update = last_scale_ != scale_ || last_offset_ != offset_;
+  bool data_update = zoom_rectangle_->HasUpdate();
+  for (const auto &line : lines_) {
+    data_update = line->HasUpdate() || data_update;
+  }
+  if (!scaling_update && !data_update) {
+    return;
+  }
+  glUseProgram(program_);
+  glBindBuffer(GL_ARRAY_BUFFER, gl_buffer_);
+  glUniform2f(scale_uniform_location_, scale_.x(), scale_.y());
+  glUniform2f(offset_uniform_location_, offset_.x(), offset_.y());
+  for (const auto &line : lines_) {
+    line->Draw();
+  }
+  zoom_rectangle_->Draw();
+  last_scale_ = scale_;
+  last_offset_ = offset_;
+}
+
+Eigen::Vector2d WebglCanvasPlotter::MaxValues() const {
+  Eigen::Vector2d max = -Vector2dInfinity();
+  for (const auto &line : lines_) {
+    max = max.cwiseMax(line->MaxValues());
+  }
+  return max;
+}
+Eigen::Vector2d WebglCanvasPlotter::MinValues() const {
+  Eigen::Vector2d min = Vector2dInfinity();
+  for (const auto &line : lines_) {
+    min = min.cwiseMin(line->MinValues());
+  }
+  return min;
+}
+
+void WebglCanvasPlotter::ClearZoomRectangle() {
+  zoom_rectangle_->SetPoints({});
+}
+
+
+void WebglCanvasPlotter::SetZoomRectangle(const Eigen::Vector2d &corner1,
+                                          const Eigen::Vector2d &corner2) {
+  zoom_rectangle_->SetPoints({corner1,
+                              {corner1.x(), corner2.y()},
+                              corner2,
+                              {corner2.x(), corner1.y()},
+                              corner1});
+}
+
+}  // namespace plotting
+}  // namespace frc971
diff --git a/frc971/analysis/plotting/webgl2_plotter.h b/frc971/analysis/plotting/webgl2_plotter.h
new file mode 100644
index 0000000..6da086c
--- /dev/null
+++ b/frc971/analysis/plotting/webgl2_plotter.h
@@ -0,0 +1,88 @@
+#ifndef FRC971_ANALYSIS_PLOTTING_WEBGL2_PLOTTER_H_
+#define FRC971_ANALYSIS_PLOTTING_WEBGL2_PLOTTER_H_
+
+#include <vector>
+
+#include <Eigen/Dense>
+#define GL_GLEXT_PROTOTYPES
+#include <GLES3/gl3.h>
+#include <GLES3/gl2ext.h>
+#include <GLES3/gl32.h>
+
+namespace frc971 {
+namespace plotting {
+
+struct Color {
+  float r;
+  float g;
+  float b;
+};
+
+class Line {
+ public:
+  virtual ~Line() {}
+  virtual void SetPoints(const std::vector<Eigen::Vector2d> &pts) = 0;
+  virtual void SetColor(const Color &color) = 0;
+  virtual void Draw() = 0;
+  virtual Eigen::Vector2d MaxValues() const = 0;
+  virtual Eigen::Vector2d MinValues() const = 0;
+  virtual void SetLineWidth(const float width) = 0;
+  virtual void SetPointSize(const float point_size) = 0;
+  virtual bool HasUpdate() = 0;
+};
+
+// TODO(james): Actually do something with this interface; originally, I'd meant
+// to look at writing some tests, but right now it's just extra boilerplate.
+class Plotter {
+ public:
+  virtual Line *AddLine() = 0;
+  virtual void SetScale(const Eigen::Vector2d &scale) = 0;
+  virtual Eigen::Vector2d GetScale() const = 0;
+  virtual void SetOffset(const Eigen::Vector2d &offset) = 0;
+  virtual Eigen::Vector2d GetOffset() const = 0;
+  virtual void Redraw() = 0;
+  virtual Eigen::Vector2d MaxValues() const = 0;
+  virtual Eigen::Vector2d MinValues() const = 0;
+  virtual void ClearZoomRectangle() = 0;
+  virtual void SetZoomRectangle(const Eigen::Vector2d &corner1,
+                                const Eigen::Vector2d &corner2) = 0;
+  virtual void RecordState() = 0;
+  virtual void Undo() = 0;
+};
+
+class WebglCanvasPlotter : public Plotter {
+ public:
+  WebglCanvasPlotter(const std::string &canvas_id,
+                     GLuint attribute_location = 0);
+  Line *AddLine() override;
+  void SetScale(const Eigen::Vector2d &scale) override;
+  Eigen::Vector2d GetScale() const override;
+  void SetOffset(const Eigen::Vector2d &offset) override;
+  Eigen::Vector2d GetOffset() const override;
+  void Redraw() override;
+  Eigen::Vector2d MaxValues() const override;
+  Eigen::Vector2d MinValues() const override;
+  void ClearZoomRectangle() override;
+  void SetZoomRectangle(const Eigen::Vector2d &corner1,
+                        const Eigen::Vector2d &corner2) override;
+  void RecordState() override;
+  void Undo() override;
+
+ private:
+  std::vector<std::unique_ptr<Line>> lines_;
+  std::unique_ptr<Line> zoom_rectangle_;
+  Eigen::Vector2d scale_{1.0, 1.0};
+  Eigen::Vector2d offset_{0.0, 0.0};
+  std::vector<Eigen::Vector2d> old_scales_;
+  std::vector<Eigen::Vector2d> old_offsets_;
+  Eigen::Vector2d last_scale_{1.0, 1.0};
+  Eigen::Vector2d last_offset_{0.0, 0.0};
+  GLuint program_;
+  GLuint scale_uniform_location_;
+  GLuint offset_uniform_location_;
+  GLuint gl_buffer_;
+};
+
+}  // namespace plotting
+}  // namespace frc971
+#endif  // FRC971_ANALYSIS_PLOTTING_WEBGL2_PLOTTER_H_
diff --git a/third_party/eigen/BUILD b/third_party/eigen/BUILD
index 54c5cd1..f3036f2 100644
--- a/third_party/eigen/BUILD
+++ b/third_party/eigen/BUILD
@@ -20,7 +20,7 @@
     ) + ["unsupported/Eigen/MatrixFunctions"] + glob([
         "unsupported/Eigen/src/MatrixFunctions/*.h",
     ]),
-    compatible_with = mcu_cpus,
+    compatible_with = mcu_cpus + ["@//tools:web"],
     includes = ["."],
     visibility = ["//visibility:public"],
 )