Proof of concept plotting for looking at robot data.
Parker created this plotting program to show how large data sets
can be viewed.
Change-Id: Ie97deccefd778be543068e42b6fb7565fb00c1e1
diff --git a/y2016/plotting/responsive_plotter.js b/y2016/plotting/responsive_plotter.js
new file mode 100644
index 0000000..0840e1d
--- /dev/null
+++ b/y2016/plotting/responsive_plotter.js
@@ -0,0 +1,238 @@
+"use strict";
+
+// Create anonymous namespace to avoid leaking these functions.
+(function() {
+// Convenience function that fetches a file and extracts the array buffer.
+function fetch_ArrayBuffer(url) { // returns Promise<ArrayBuffer>
+ return fetch(url).then(function(val) { return val.arrayBuffer(); });
+}
+
+// Two dimensional vector.
+class Point2d {
+ constructor(x, y) {
+ this.x = x;
+ this.y = y;
+ }
+ sub(o) { return new Point2d(this.x - o.x, this.y - o.y); }
+ add(o) { return new Point2d(this.x + o.x, this.y + o.y); }
+};
+
+// Drag event object for updating a Point2d class by reference.
+class PointDrag {
+ constructor(point) { // point : Point2d
+ this.point = point;
+ }
+ doDrag(delta) {
+ this.point.x += delta.x;
+ this.point.y += delta.y;
+ }
+ doMouseUp(e) { return null; }
+}
+
+// Takes in a canvas and adds the ability to click and drag the offset point.
+// scaleX and scaleY are allowed to be locked or not-locked as the scroll events
+// must be handled by the user of this class.
+//
+// offset.x and offset.y are defined in reference to the model-scale, not the viewport.
+// Thus to move the viewport you need to use offset.x * scaleX, etc.
+//
+// scaleX and scaleY aren't automatically applied to the rendering context because
+// differing x and y scales mess up the line width rendering.
+class ClickDrag {
+ constructor(canvas) {
+ canvas.addEventListener('selectstart', function(e) { e.preventDefault(); return false; }, false);
+ canvas.addEventListener('mousemove', this.handleMouseMove.bind(this), true);
+ canvas.addEventListener('mousedown', this.handleMouseDown.bind(this), true);
+ canvas.addEventListener('mouseup', this.handleMouseUp.bind(this), true);
+ this.canvas = canvas;
+ this.dragger = null;
+ this.offset = new Point2d(0, 0);
+ this.scaleX = 1.0;
+ this.scaleY = 1.0;
+ this.old_point = null;
+ this.on_redraw = null;
+ }
+ translateContext(ctx) { // ctx : CanvasRenderingContext2D
+ ctx.translate(this.offset.x * this.scaleX, this.offset.y * this.scaleY);
+ }
+ handleMouseMove(e) { // e : MouseEvent
+ let pos = new Point2d(e.offsetX / this.scaleX, e.offsetY / this.scaleY);
+ if (this.old_point !== null && this.dragger !== null) {
+ this.dragger.doDrag(pos.sub(this.old_point));
+ if (this.on_redraw !== null) { this.on_redraw(); }
+ }
+ this.old_point = pos;
+ }
+ get width() { return this.canvas.width; }
+ handleMouseDown(e) { // e : MouseEvent
+ this.dragger = new PointDrag(this.offset);
+ }
+ handleMouseUp(e) { // e : MouseEvent
+ if (this.dragger === null) { return; }
+ if (this.dragger.doMouseUp !== undefined) {
+ this.dragger = this.dragger.doMouseUp(e);
+ if (this.on_redraw !== null) { this.on_redraw(); }
+ }
+ }
+ multiplyScale(evt, dx, dy) {
+ let x_cur = evt.offsetX / this.scaleX - this.offset.x;
+ let y_cur = evt.offsetY / this.scaleY - this.offset.y;
+ this.scaleX *= dx;
+ this.scaleY *= dy;
+ this.offset.x = evt.offsetX / this.scaleX - x_cur;
+ this.offset.y = evt.offsetY / this.scaleY - y_cur;
+ if (this.on_redraw !== null) { this.on_redraw(); }
+ }
+};
+
+// This represents a particular downsampling of a timeseries dataset.
+// For the base level, data = min = max.
+// This is required
+class MipMapLevel {
+ // All arrays should be the same size.
+ constructor(data, min, max, scale) { // data : Float32Array, min : Float32Array, max : Float32Array, scale : Integer
+ this.data = data;
+ this.min = min;
+ this.max = max;
+ this.scale = scale;
+ }
+ get length() { return this.data.length; }
+ // Uses box filter to downsample. Guassian may be an improvement.
+ downsample() {
+ let new_length = (this.data.length / 2) | 0;
+ let data = new Float32Array(new_length);
+ let min = new Float32Array(new_length);
+ let max = new Float32Array(new_length);
+ for (let i = 0; i < new_length; i++) {
+ let i1 = i * 2;
+ let i2 = i1 + 1
+ data[i] = (this.data[i1] + this.data[i2]) / 2.0;
+ min[i] = Math.min(this.min[i1], this.min[i2]);
+ max[i] = Math.max(this.max[i1], this.max[i2]);
+ }
+ return new MipMapLevel(data, min, max, this.scale * 2);
+ }
+ static makeBaseLevel(data) { // data : Float32Array
+ return new MipMapLevel(data, data, data, 1);
+ }
+ clipv(v) { // v : Numeric
+ return Math.min(Math.max(v|0, 0), this.length - 1);
+ }
+ draw(ctx, clickDrag) { // ctx : CanvasRenderingContext2D, clickDrag : ClickDrag
+ let scalex = clickDrag.scaleX * this.scale;
+ let scaley = clickDrag.scaleY;
+ let stx = -clickDrag.offset.x / this.scale;
+ let edx = stx + clickDrag.width / scalex;
+ let sti = this.clipv(stx - 2);
+ let edi = this.clipv(edx + 2);
+ ctx.beginPath();
+ ctx.fillStyle="#00ff00";
+ ctx.moveTo(sti * scalex, scaley * this.min[sti]);
+ for (let i = sti + 1; i <= edi; i++) {
+ ctx.lineTo(i * scalex, scaley * this.min[i]);
+ }
+ for (let i = edi; i >= sti; --i) {
+ ctx.lineTo(i * scalex, scaley * this.max[i]);
+ }
+ ctx.fill();
+ ctx.closePath();
+
+ ctx.beginPath();
+ ctx.strokeStyle="#008f00";
+ ctx.moveTo(sti * scalex, scaley * this.data[sti]);
+ for (let i = sti + 1; i <= edi; i++) {
+ ctx.lineTo(i * scalex, scaley * this.data[i]);
+ }
+ ctx.stroke();
+ ctx.closePath();
+ }
+};
+
+// This class represents all downsampled data levels.
+// This must only be used for descrite time-series data.
+class MipMapper {
+ constructor(samples) { // samples : Float32Array
+ this.levels = [];
+ let level = MipMapLevel.makeBaseLevel(samples);
+ this.levels.push(level);
+ while (level.length > 2) {
+ level = level.downsample();
+ this.levels.push(level);
+ }
+ }
+ // Find the level such that samples are spaced at most 2 pixels apart
+ getLevel(scale) { // scale : Float
+ let level = this.levels[0];
+ for (let i = 0; i < this.levels.length; ++i) {
+ // Someone who understands nyquist could probably improve this.
+ if (this.levels[i].scale * scale <= 4.0) {
+ level = this.levels[i];
+ }
+ }
+ return level;
+ }
+}
+
+// Custom HTML element for x-responsive-plotter.
+// This should be the main entry-point for this code.
+class ResponsivePlotter extends HTMLElement {
+ createdCallback() {
+ this.data = null;
+ this.canvas = document.createElement("canvas");
+ this.canvas.style["background"] = "inherit";
+ this.canvas.addEventListener("mousewheel", this.doScroll.bind(this), false);
+ this.clickDrag = new ClickDrag(this.canvas);
+ this.clickDrag.on_redraw = this.doRedraw.bind(this);
+ this.clickDrag.offset.y = 160; //320;
+ this.updateInnerData();
+ this.appendChild(this.canvas);
+ }
+ doScroll(e) {
+ let factor = (e.wheelDelta < 0) ? 0.9 : 1.1;
+ this.clickDrag.multiplyScale(e, e.shiftKey ? 1.0 : factor, e.shiftKey ? factor : 1.0);
+ e.preventDefault();
+ }
+ doRedraw() {
+ this.redraw();
+ }
+ updateInnerData() {
+ this.canvas.width = this.getAttribute("width") || 640;
+ this.canvas.height = this.getAttribute("height") || 480;
+ fetch_ArrayBuffer(this.getAttribute("data")).then(this.dataUpdated.bind(this));
+ this.redraw();
+ }
+ attributeChangedCallback(attrName, oldVal, newVal) {
+ if (attrName == "width") {
+ this.canvas.width = this.getAttribute("width") || 640;
+ this.redraw();
+ } else if (attrName == "height") {
+ this.canvas.width = this.getAttribute("height") || 480;
+ this.redraw();
+ } else if (attrName == "data") {
+ // Should this clear out the canvas if a different url is selected?
+ // Well it does.
+ this.data = null;
+ fetch_ArrayBuffer(this.getAttribute("data")).then(this.dataUpdated.bind(this));
+ }
+ }
+ dataUpdated(data) { // data : ArrayBuffer
+ this.data = new MipMapper(new Float32Array(data));
+ this.redraw();
+ }
+ redraw() {
+ let ctx = this.canvas.getContext('2d');
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+ ctx.save();
+ this.clickDrag.translateContext(ctx);
+ if (this.data !== null) {
+ let level = this.data.getLevel(this.clickDrag.scaleX);
+ level.draw(ctx, this.clickDrag);
+ }
+ ctx.restore();
+ }
+};
+
+
+// TODO(parker): This is chrome-specific v0 version of this switch to v1 when available.
+document.registerElement('x-responsive-plotter', ResponsivePlotter);
+})();