module Plottable { export class DragZoomLayer extends Components.SelectionBoxLayer { private _dragInteraction: Interactions.Drag; private _doubleClickInteraction: Interactions.DoubleClick; private xDomainToRestore: any[]; private yDomainToRestore: any[]; private isZoomed = false; private easeFn: (t: number) => number = d3.ease("cubic-in-out"); private _animationTime = 750; /* Constructs a SelectionBoxLayer with an attached DragInteraction and ClickInteraction. * On drag, it triggers an animated zoom into the box that was dragged. * On double click, it zooms back out to the original view, before any zooming. * The zoom animation uses an easing function (default d3.ease("cubic-in-out")) and is customizable. * Usage: Construct the selection box layer and attach x and y scales, and then add the layer * over the plot you are zooming on using a Component Group. * TODO(danmane) - merge this into Plottable */ constructor(xScale: QuantitativeScale, yScale: QuantitativeScale) { super(); this.xScale(xScale); this.yScale(yScale); this._dragInteraction = new Interactions.Drag(); this._dragInteraction.attachTo(this); this._doubleClickInteraction = new Interactions.DoubleClick(); this._doubleClickInteraction.attachTo(this); this.setupCallbacks(); } private setupCallbacks() { let dragging = false; this._dragInteraction.onDragStart((startPoint: Point) => { this.bounds({ topLeft: startPoint, bottomRight: startPoint, }); }); this._dragInteraction.onDrag((startPoint, endPoint) => { this.bounds({topLeft: startPoint, bottomRight: endPoint}); this.boxVisible(true); dragging = true; }); this._dragInteraction.onDragEnd((startPoint, endPoint) => { this.boxVisible(false); this.bounds({topLeft: startPoint, bottomRight: endPoint}); if (dragging) { this.zoom(); } dragging = false; }); this._doubleClickInteraction.onDoubleClick(this.unzoom.bind(this)); } /* Set the time (in ms) over which the zoom will interpolate. * 0 implies no interpolation. (ie zoom is instant) */ public animationTime(): number; public animationTime(animationTime: number): DragZoomLayer; public animationTime(animationTime?: number): any { if (animationTime == null) { return this._animationTime; } if (animationTime < 0) { throw new Error("animationTime cannot be negative"); } this._animationTime = animationTime; return this; } /* Set the easing function, which determines how the zoom interpolates over time. */ public ease(fn: (t: number) => number): DragZoomLayer { if (typeof(fn) !== "function") { throw new Error("ease function must be a function"); } if (fn(0) !== 0 || fn(1) !== 1) { Utils.Window.warn("Easing function does not maintain invariant f(0)==0 && f(1)==1. Bad behavior may result."); } this.easeFn = fn; return this; } // Zoom into extent of the selection box bounds private zoom() { let x0: number = this.xExtent()[0].valueOf(); let x1: number = this.xExtent()[1].valueOf(); let y0: number = this.yExtent()[1].valueOf(); let y1: number = this.yExtent()[0].valueOf(); if (x0 === x1 || y0 === y1) { return; } if (!this.isZoomed) { this.isZoomed = true; this.xDomainToRestore = this.xScale().domain(); this.yDomainToRestore = this.yScale().domain(); } this.interpolateZoom(x0, x1, y0, y1); } // Restore the scales to their state before any zoom private unzoom() { if (!this.isZoomed) { return; } this.isZoomed = false; this.interpolateZoom(this.xDomainToRestore[0], this.xDomainToRestore[1], this.yDomainToRestore[0], this.yDomainToRestore[1]); } // If we are zooming, disable interactions, to avoid contention private isZooming(isZooming: boolean) { this._dragInteraction.enabled(!isZooming); this._doubleClickInteraction.enabled(!isZooming); } private interpolateZoom(x0f: number, x1f: number, y0f: number, y1f: number) { let x0s: number = this.xScale().domain()[0].valueOf(); let x1s: number = this.xScale().domain()[1].valueOf(); let y0s: number = this.yScale().domain()[0].valueOf(); let y1s: number = this.yScale().domain()[1].valueOf(); // Copy a ref to the ease fn, so that changing ease wont affect zooms in progress let ease = this.easeFn; let interpolator = (a: number, b: number, p: number) => d3.interpolateNumber(a, b)(ease(p)); this.isZooming(true); let start = Date.now(); let draw = () => { let now = Date.now(); let passed = now - start; let p = this._animationTime === 0 ? 1 : Math.min(1, passed / this._animationTime); let x0 = interpolator(x0s, x0f, p); let x1 = interpolator(x1s, x1f, p); let y0 = interpolator(y0s, y0f, p); let y1 = interpolator(y1s, y1f, p); this.xScale().domain([x0, x1]); this.yScale().domain([y0, y1]); if (p < 1) { Utils.DOM.requestAnimationFramePolyfill(draw); } else { this.isZooming(false); } }; draw(); } } }