Michael Schuh | 6ad2469 | 2016-08-24 20:58:31 -0700 | [diff] [blame] | 1 | "use strict"; |
| 2 | |
| 3 | // Create anonymous namespace to avoid leaking these functions. |
| 4 | (function() { |
| 5 | // Convenience function that fetches a file and extracts the array buffer. |
| 6 | function fetch_ArrayBuffer(url) { // returns Promise<ArrayBuffer> |
| 7 | return fetch(url).then(function(val) { return val.arrayBuffer(); }); |
| 8 | } |
| 9 | |
| 10 | // Two dimensional vector. |
| 11 | class Point2d { |
| 12 | constructor(x, y) { |
| 13 | this.x = x; |
| 14 | this.y = y; |
| 15 | } |
| 16 | sub(o) { return new Point2d(this.x - o.x, this.y - o.y); } |
| 17 | add(o) { return new Point2d(this.x + o.x, this.y + o.y); } |
| 18 | }; |
| 19 | |
| 20 | // Drag event object for updating a Point2d class by reference. |
| 21 | class PointDrag { |
| 22 | constructor(point) { // point : Point2d |
| 23 | this.point = point; |
| 24 | } |
| 25 | doDrag(delta) { |
| 26 | this.point.x += delta.x; |
| 27 | this.point.y += delta.y; |
| 28 | } |
| 29 | doMouseUp(e) { return null; } |
| 30 | } |
| 31 | |
| 32 | // Takes in a canvas and adds the ability to click and drag the offset point. |
| 33 | // scaleX and scaleY are allowed to be locked or not-locked as the scroll events |
| 34 | // must be handled by the user of this class. |
| 35 | // |
| 36 | // offset.x and offset.y are defined in reference to the model-scale, not the viewport. |
| 37 | // Thus to move the viewport you need to use offset.x * scaleX, etc. |
| 38 | // |
| 39 | // scaleX and scaleY aren't automatically applied to the rendering context because |
| 40 | // differing x and y scales mess up the line width rendering. |
| 41 | class ClickDrag { |
| 42 | constructor(canvas) { |
| 43 | canvas.addEventListener('selectstart', function(e) { e.preventDefault(); return false; }, false); |
| 44 | canvas.addEventListener('mousemove', this.handleMouseMove.bind(this), true); |
| 45 | canvas.addEventListener('mousedown', this.handleMouseDown.bind(this), true); |
| 46 | canvas.addEventListener('mouseup', this.handleMouseUp.bind(this), true); |
| 47 | this.canvas = canvas; |
| 48 | this.dragger = null; |
| 49 | this.offset = new Point2d(0, 0); |
| 50 | this.scaleX = 1.0; |
| 51 | this.scaleY = 1.0; |
| 52 | this.old_point = null; |
| 53 | this.on_redraw = null; |
| 54 | } |
| 55 | translateContext(ctx) { // ctx : CanvasRenderingContext2D |
| 56 | ctx.translate(this.offset.x * this.scaleX, this.offset.y * this.scaleY); |
| 57 | } |
| 58 | handleMouseMove(e) { // e : MouseEvent |
| 59 | let pos = new Point2d(e.offsetX / this.scaleX, e.offsetY / this.scaleY); |
| 60 | if (this.old_point !== null && this.dragger !== null) { |
| 61 | this.dragger.doDrag(pos.sub(this.old_point)); |
| 62 | if (this.on_redraw !== null) { this.on_redraw(); } |
| 63 | } |
| 64 | this.old_point = pos; |
| 65 | } |
| 66 | get width() { return this.canvas.width; } |
| 67 | handleMouseDown(e) { // e : MouseEvent |
| 68 | this.dragger = new PointDrag(this.offset); |
| 69 | } |
| 70 | handleMouseUp(e) { // e : MouseEvent |
| 71 | if (this.dragger === null) { return; } |
| 72 | if (this.dragger.doMouseUp !== undefined) { |
| 73 | this.dragger = this.dragger.doMouseUp(e); |
| 74 | if (this.on_redraw !== null) { this.on_redraw(); } |
| 75 | } |
| 76 | } |
| 77 | multiplyScale(evt, dx, dy) { |
| 78 | let x_cur = evt.offsetX / this.scaleX - this.offset.x; |
| 79 | let y_cur = evt.offsetY / this.scaleY - this.offset.y; |
| 80 | this.scaleX *= dx; |
| 81 | this.scaleY *= dy; |
| 82 | this.offset.x = evt.offsetX / this.scaleX - x_cur; |
| 83 | this.offset.y = evt.offsetY / this.scaleY - y_cur; |
| 84 | if (this.on_redraw !== null) { this.on_redraw(); } |
| 85 | } |
| 86 | }; |
| 87 | |
| 88 | // This represents a particular downsampling of a timeseries dataset. |
| 89 | // For the base level, data = min = max. |
| 90 | // This is required |
| 91 | class MipMapLevel { |
| 92 | // All arrays should be the same size. |
| 93 | constructor(data, min, max, scale) { // data : Float32Array, min : Float32Array, max : Float32Array, scale : Integer |
| 94 | this.data = data; |
| 95 | this.min = min; |
| 96 | this.max = max; |
| 97 | this.scale = scale; |
| 98 | } |
| 99 | get length() { return this.data.length; } |
| 100 | // Uses box filter to downsample. Guassian may be an improvement. |
| 101 | downsample() { |
| 102 | let new_length = (this.data.length / 2) | 0; |
| 103 | let data = new Float32Array(new_length); |
| 104 | let min = new Float32Array(new_length); |
| 105 | let max = new Float32Array(new_length); |
| 106 | for (let i = 0; i < new_length; i++) { |
| 107 | let i1 = i * 2; |
| 108 | let i2 = i1 + 1 |
| 109 | data[i] = (this.data[i1] + this.data[i2]) / 2.0; |
| 110 | min[i] = Math.min(this.min[i1], this.min[i2]); |
| 111 | max[i] = Math.max(this.max[i1], this.max[i2]); |
| 112 | } |
| 113 | return new MipMapLevel(data, min, max, this.scale * 2); |
| 114 | } |
| 115 | static makeBaseLevel(data) { // data : Float32Array |
| 116 | return new MipMapLevel(data, data, data, 1); |
| 117 | } |
| 118 | clipv(v) { // v : Numeric |
| 119 | return Math.min(Math.max(v|0, 0), this.length - 1); |
| 120 | } |
| 121 | draw(ctx, clickDrag) { // ctx : CanvasRenderingContext2D, clickDrag : ClickDrag |
| 122 | let scalex = clickDrag.scaleX * this.scale; |
| 123 | let scaley = clickDrag.scaleY; |
| 124 | let stx = -clickDrag.offset.x / this.scale; |
| 125 | let edx = stx + clickDrag.width / scalex; |
| 126 | let sti = this.clipv(stx - 2); |
| 127 | let edi = this.clipv(edx + 2); |
| 128 | ctx.beginPath(); |
| 129 | ctx.fillStyle="#00ff00"; |
| 130 | ctx.moveTo(sti * scalex, scaley * this.min[sti]); |
| 131 | for (let i = sti + 1; i <= edi; i++) { |
| 132 | ctx.lineTo(i * scalex, scaley * this.min[i]); |
| 133 | } |
| 134 | for (let i = edi; i >= sti; --i) { |
| 135 | ctx.lineTo(i * scalex, scaley * this.max[i]); |
| 136 | } |
| 137 | ctx.fill(); |
| 138 | ctx.closePath(); |
| 139 | |
| 140 | ctx.beginPath(); |
| 141 | ctx.strokeStyle="#008f00"; |
| 142 | ctx.moveTo(sti * scalex, scaley * this.data[sti]); |
| 143 | for (let i = sti + 1; i <= edi; i++) { |
| 144 | ctx.lineTo(i * scalex, scaley * this.data[i]); |
| 145 | } |
| 146 | ctx.stroke(); |
| 147 | ctx.closePath(); |
| 148 | } |
| 149 | }; |
| 150 | |
| 151 | // This class represents all downsampled data levels. |
| 152 | // This must only be used for descrite time-series data. |
| 153 | class MipMapper { |
| 154 | constructor(samples) { // samples : Float32Array |
| 155 | this.levels = []; |
| 156 | let level = MipMapLevel.makeBaseLevel(samples); |
| 157 | this.levels.push(level); |
| 158 | while (level.length > 2) { |
| 159 | level = level.downsample(); |
| 160 | this.levels.push(level); |
| 161 | } |
| 162 | } |
| 163 | // Find the level such that samples are spaced at most 2 pixels apart |
| 164 | getLevel(scale) { // scale : Float |
| 165 | let level = this.levels[0]; |
| 166 | for (let i = 0; i < this.levels.length; ++i) { |
| 167 | // Someone who understands nyquist could probably improve this. |
| 168 | if (this.levels[i].scale * scale <= 4.0) { |
| 169 | level = this.levels[i]; |
| 170 | } |
| 171 | } |
| 172 | return level; |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // Custom HTML element for x-responsive-plotter. |
| 177 | // This should be the main entry-point for this code. |
| 178 | class ResponsivePlotter extends HTMLElement { |
| 179 | createdCallback() { |
| 180 | this.data = null; |
| 181 | this.canvas = document.createElement("canvas"); |
| 182 | this.canvas.style["background"] = "inherit"; |
| 183 | this.canvas.addEventListener("mousewheel", this.doScroll.bind(this), false); |
| 184 | this.clickDrag = new ClickDrag(this.canvas); |
| 185 | this.clickDrag.on_redraw = this.doRedraw.bind(this); |
| 186 | this.clickDrag.offset.y = 160; //320; |
| 187 | this.updateInnerData(); |
| 188 | this.appendChild(this.canvas); |
| 189 | } |
| 190 | doScroll(e) { |
| 191 | let factor = (e.wheelDelta < 0) ? 0.9 : 1.1; |
| 192 | this.clickDrag.multiplyScale(e, e.shiftKey ? 1.0 : factor, e.shiftKey ? factor : 1.0); |
| 193 | e.preventDefault(); |
| 194 | } |
| 195 | doRedraw() { |
| 196 | this.redraw(); |
| 197 | } |
| 198 | updateInnerData() { |
| 199 | this.canvas.width = this.getAttribute("width") || 640; |
| 200 | this.canvas.height = this.getAttribute("height") || 480; |
| 201 | fetch_ArrayBuffer(this.getAttribute("data")).then(this.dataUpdated.bind(this)); |
| 202 | this.redraw(); |
| 203 | } |
| 204 | attributeChangedCallback(attrName, oldVal, newVal) { |
| 205 | if (attrName == "width") { |
| 206 | this.canvas.width = this.getAttribute("width") || 640; |
| 207 | this.redraw(); |
| 208 | } else if (attrName == "height") { |
| 209 | this.canvas.width = this.getAttribute("height") || 480; |
| 210 | this.redraw(); |
| 211 | } else if (attrName == "data") { |
| 212 | // Should this clear out the canvas if a different url is selected? |
| 213 | // Well it does. |
| 214 | this.data = null; |
| 215 | fetch_ArrayBuffer(this.getAttribute("data")).then(this.dataUpdated.bind(this)); |
| 216 | } |
| 217 | } |
| 218 | dataUpdated(data) { // data : ArrayBuffer |
| 219 | this.data = new MipMapper(new Float32Array(data)); |
| 220 | this.redraw(); |
| 221 | } |
| 222 | redraw() { |
| 223 | let ctx = this.canvas.getContext('2d'); |
| 224 | ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); |
| 225 | ctx.save(); |
| 226 | this.clickDrag.translateContext(ctx); |
| 227 | if (this.data !== null) { |
| 228 | let level = this.data.getLevel(this.clickDrag.scaleX); |
| 229 | level.draw(ctx, this.clickDrag); |
| 230 | } |
| 231 | ctx.restore(); |
| 232 | } |
| 233 | }; |
| 234 | |
| 235 | |
| 236 | // TODO(parker): This is chrome-specific v0 version of this switch to v1 when available. |
| 237 | document.registerElement('x-responsive-plotter', ResponsivePlotter); |
| 238 | })(); |