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