blob: 0840e1d3a4770d5c67f63aa674968a0574735fe8 [file] [log] [blame]
"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);
})();