UNPKG

escher-vis

Version:

Escher: A Web Application for Building, Sharing, and Embedding Data-Rich Visualizations of Biological Pathways

471 lines (414 loc) 15 kB
/** * ZoomContainer */ var utils = require('./utils') var CallbackManager = require('./CallbackManager') var _ = require('underscore') var d3_zoom = require('d3-zoom').zoom var d3_zoomTransform = require('d3-zoom').zoomTransform var d3_zoomIdentity = require('d3-zoom').zoomIdentity var d3_select = require('d3-selection').select var d3_selection = require('d3-selection') var ZoomContainer = utils.make_class() ZoomContainer.prototype = { init: init, set_scroll_behavior: set_scroll_behavior, set_use_3d_transform: set_use_3d_transform, _update_scroll: _update_scroll, toggle_pan_drag: toggle_pan_drag, go_to: go_to, _go_to_callback: _go_to_callback, _go_to_3d: _go_to_3d, _clear_3d: _clear_3d, _go_to_svg: _go_to_svg, zoom_by: zoom_by, zoom_in: zoom_in, zoom_out: zoom_out, get_size: get_size, translate_off_screen: translate_off_screen, } module.exports = ZoomContainer /** * Make a container that will manage panning and zooming. Creates a new SVG * element, with a parent div for CSS3 3D transforms. * * @param {D3 Selection} selection - A d3 selection of a HTML node to put the * zoom container in. Should have a defined width and height. * * @param {String} scroll_behavior - Either 'zoom' or 'pan'. * * @param {Boolean} use_3d_transform - If true, then use CSS3 3D transform to * speed up pan and zoom. * * @param {Boolean} fill_screen - If true, then apply styles to body and * selection that fill the screen. The styled classes are 'fill-screen-body' and * 'fill-screen-div'. */ function init (selection, scroll_behavior, use_3d_transform, fill_screen) { // set the selection class selection.classed('escher-container', true) // Stop scrolling on mobile selection.on('touchstart touchmove', function () { d3_selection.event.stopPropagation() }) // fill screen classes if (fill_screen) { d3_select('html').classed('fill-screen', true) d3_select('body').classed('fill-screen', true) selection.classed('fill-screen-div', true) } // make the svg var zoom_container = selection.append('div') .attr('class', 'escher-zoom-container') var css3_transform_container = zoom_container.append('div') .attr('class', 'escher-3d-transform-container') var svg = css3_transform_container.append('svg') .attr('class', 'escher-svg') .attr('xmlns', 'http://www.w3.org/2000/svg') // set up the zoom container svg.select('.zoom-g').remove() var zoomed_sel = svg.append('g').attr('class', 'zoom-g') // attributes this.selection = selection this.zoom_container = zoom_container this.css3_transform_container = css3_transform_container this.svg = svg this.zoomed_sel = zoomed_sel this.window_translate = { x: 0, y: 0 } this.window_scale = 1.0 this._scroll_behavior = scroll_behavior this._use_3d_transform = use_3d_transform this._pan_drag_on = true this._zoom_behavior = null this._zoom_timeout = null this._svg_scale = this.window_scale this._svg_translate = this.window_translate // this._last_svg_ms = null // set up the callbacks this.callback_manager = new CallbackManager() // update the scroll behavior this._update_scroll() } /** * Set up pan or zoom on scroll. * @param {String} scroll_behavior - 'none', 'pan' or 'zoom'. */ function set_scroll_behavior (scroll_behavior) { this._scroll_behavior = scroll_behavior this._update_scroll() } /** * Set the option use_3d_transform */ function set_use_3d_transform (use_3d_transform) { this._use_3d_transform = use_3d_transform } /** * Toggle the zoom drag and the cursor UI for it. */ function toggle_pan_drag (on_off) { if (_.isUndefined(on_off)) { this._pan_drag_on = !this._pan_drag_on } else { this._pan_drag_on = on_off } if (this._pan_drag_on) { // turn on the hand this.zoomed_sel .classed('cursor-grab', true) .classed('cursor-grabbing', false) } else { // turn off the hand this.zoomed_sel.style('cursor', null) .classed('cursor-grab', false) .classed('cursor-grabbing', false) } // update the behaviors this._update_scroll() } /** * Update the pan and zoom behaviors. The behaviors are applied to the * css3_transform_container node. */ function _update_scroll () { if (!_.contains([ 'zoom', 'pan', 'none' ], this._scroll_behavior)) { throw Error('Bad value for scroll_behavior: ' + this._scroll_behavior) } // clear all behaviors this.zoom_container.on('mousewheel.zoom', null) // zoom scroll behaviors .on('DOMMouseScroll.zoom', null) // disables older versions of Firefox .on('wheel.zoom', null) // disables newer versions of Firefox .on('dblclick.zoom', null) .on('mousewheel.escher', null) // pan scroll behaviors .on('DOMMouseScroll.escher', null) .on('wheel.escher', null) .on('mousedown.zoom', null) // drag behaviors .on('touchstart.zoom', null) .on('touchmove.zoom', null) .on('touchend.zoom', null) // This handles dragging to pan, and touch events (in any scroll mode). It // also handles scrolling to zoom (only 'zoom' mode). It also raises an // exception in node, so catch that during testing. This may be a bug with // d3 related to d3 using the global this.document. TODO look into this. this._zoom_behavior = d3_zoom() .on('start', function () { if (d3_selection.event.sourceEvent && d3_selection.event.sourceEvent.type === 'mousedown') { this.zoomed_sel .classed('cursor-grab', false) .classed('cursor-grabbing', true) } // Prevent default zoom behavior, specifically for mobile pinch zoom if (d3_selection.event.sourceEvent !== null) { d3_selection.event.sourceEvent.stopPropagation() d3_selection.event.sourceEvent.preventDefault() } }.bind(this)) .on('zoom', function () { this._go_to_callback(d3_selection.event.transform.k, { x: d3_selection.event.transform.x, y: d3_selection.event.transform.y, }) }.bind(this)) .on('end', function () { if (d3_selection.event.sourceEvent && d3_selection.event.sourceEvent.type === 'mouseup') { this.zoomed_sel .classed('cursor-grab', true) .classed('cursor-grabbing', false) } }.bind(this)) // Set it up this.zoom_container.call(this._zoom_behavior) // Always turn off double-clicking to zoom this.zoom_container.on('dblclick.zoom', null) // If panning is off, then turn off these listeners if (!this._pan_drag_on) { this.zoom_container.on('mousedown.zoom', null) .on('touchstart.zoom', null) .on('touchmove.zoom', null) .on('touchend.zoom', null) } // If scroll to zoom is off, then turn off these listeners if (this._scroll_behavior !== 'zoom') { this.zoom_container .on('mousewheel.zoom', null) // zoom scroll behaviors .on('DOMMouseScroll.zoom', null) // disables older versions of Firefox .on('wheel.zoom', null); // disables newer versions of Firefox } // add listeners for scrolling to pan if (this._scroll_behavior === 'pan') { // Add the wheel listener var wheel_fn = function () { var ev = d3_selection.event var sensitivity = 0.5 // stop scroll in parent elements ev.stopPropagation() ev.preventDefault() ev.returnValue = false // change the location var get_directional_disp = function (wheel_delta, delta) { var the_delt = _.isUndefined(wheel_delta) ? delta : -wheel_delta / 1.5 return the_delt * sensitivity } var new_translate = { x: this.window_translate.x - get_directional_disp(ev.wheelDeltaX, ev.deltaX), y: this.window_translate.y - get_directional_disp(ev.wheelDeltaY, ev.deltaY), } this.go_to(this.window_scale, new_translate) }.bind(this) // apply it this.zoom_container.on('mousewheel.escher', wheel_fn) this.zoom_container.on('DOMMouseScroll.escher', wheel_fn) this.zoom_container.on('wheel.escher', wheel_fn) } // Set current location this.go_to(this.window_scale, this.window_translate) } // ------------------------------------------------------------ // Functions to scale and translate // ------------------------------------------------------------ /** * Zoom the container to a specified location. * @param {Number} scale - The scale, between 0 and 1. * @param {Object} translate - The location, of the form {x: 2.0, y: 3.0}. */ function go_to (scale, translate) { utils.check_undefined(arguments, [ 'scale', 'translate' ]) // Check inputs if (!scale) { throw new Error('Bad scale value') } if (!translate || !('x' in translate) || !('y' in translate) || _.isNaN(translate.x) || _.isNaN(translate.y)) { throw new Error('Bad translate value') } // Save to zoom behavior, which will call _go_to_callback var new_zoom = d3_zoomIdentity .translate(translate.x, translate.y) .scale(scale) this.zoom_container.call(this._zoom_behavior.transform, new_zoom) } /** * Execute the zoom called by the d3 zoom behavior. * @param {Number} scale - The scale, between 0 and 1 * @param {Object} translate - The location, of the form { x: 2.0, y: 3.0 } */ function _go_to_callback (scale, translate) { this.window_scale = scale this.window_translate = translate var use_3d_transform = this._use_3d_transform if (use_3d_transform) { // 3d tranform // cancel all timeouts if (!_.isNull(this._zoom_timeout)) { clearTimeout(this._zoom_timeout) } // set the 3d transform this._go_to_3d(scale, translate, this._svg_scale, this._svg_translate) // if another go_to does not happen within the delay time, then // redraw the svg this._zoom_timeout = _.delay(function () { // redraw the svg this._go_to_svg(scale, translate) }.bind(this), 100); // between 100 and 600 seems to be usable } else { // no 3d transform this._go_to_svg(scale, translate) } this.callback_manager.run('go_to') } /** * Zoom & pan the CSS 3D transform container */ function _go_to_3d (scale, translate, svg_scale, svg_translate) { var n_scale = scale / svg_scale var n_translate = utils.c_minus_c(translate, utils.c_times_scalar(svg_translate, n_scale)) var transform = ('translate(' + n_translate.x + 'px,' + n_translate.y + 'px) ' + 'scale(' + n_scale + ')') this.css3_transform_container.style('transform', transform) this.css3_transform_container.style('-webkit-transform', transform) this.css3_transform_container.style('transform-origin', '0 0') this.css3_transform_container.style('-webkit-transform-origin', '0 0') } function _clear_3d () { this.css3_transform_container.style('transform', null) this.css3_transform_container.style('-webkit-transform', null) this.css3_transform_container.style('transform-origin', null) this.css3_transform_container.style('-webkit-transform-origin', null) } /** * Zoom & pan the svg element. Also runs the svg_start and svg_finish callbacks. * @param {Number} scale - The scale, between 0 and 1. * @param {Object} translate - The location, of the form {x: 2.0, y: 3.0}. * @param {Function} callback - (optional) A callback to run after scaling. */ function _go_to_svg (scale, translate, callback) { this.callback_manager.run('svg_start') // defer to update callbacks _.defer(function () { // start time // var start = new Date().getTime() // reset the 3d transform this._clear_3d() // redraw the svg this.zoomed_sel .attr('transform', 'translate(' + translate.x + ',' + translate.y + ') ' + 'scale(' + scale + ')') // save svg location this._svg_scale = scale this._svg_translate = translate _.defer(function () { // defer for callback after draw this.callback_manager.run('svg_finish') if (!_.isUndefined(callback)) callback() // wait a few ms to get a reliable end time // _.delay(function () { // // end time // var t = new Date().getTime() - start // this._last_svg_ms = t // }.bind(this), 20) }.bind(this)) }.bind(this)) } /** * Zoom by a specified multiplier. * @param {Number} amount - A multiplier for the zoom. Greater than 1 zooms in * and less than 1 zooms out. */ function zoom_by (amount) { var size = this.get_size() var shift = { x: size.width/2 - ((size.width/2 - this.window_translate.x) * amount + this.window_translate.x), y: size.height/2 - ((size.height/2 - this.window_translate.y) * amount + this.window_translate.y), } this.go_to(this.window_scale * amount, utils.c_plus_c(this.window_translate, shift)) } /** * Zoom in by the default amount with the default options. */ function zoom_in () { this.zoom_by(1.5) } /** * Zoom out by the default amount with the default options. */ function zoom_out () { this.zoom_by(0.667) } /** * Return the size of the zoom container as coordinates. Throws an error if * width or height is not defined. * @returns {Object} The size coordinates, e.g. { x: 2, y: 3 }. */ function get_size () { var width = parseInt(this.selection.style('width'), 10) var height = parseInt(this.selection.style('height'), 10) if (_.isNaN(width) || _.isNaN(height)) { throw new Error('Size not defined for ZoomContainer element.') } return { width: width, height: height } } /** * Shift window if new reaction will draw off the screen. */ function translate_off_screen (coords) { // TODO BUG not accounting for scale correctly var margin = 120 // pixels var size = this.get_size() var current = { x: { min: - this.window_translate.x / this.window_scale + margin / this.window_scale, max: - this.window_translate.x / this.window_scale + (size.width-margin) / this.window_scale, }, y: { min: - this.window_translate.y / this.window_scale + margin / this.window_scale, max: - this.window_translate.y / this.window_scale + (size.height-margin) / this.window_scale, }, } if (coords.x < current.x.min) { this.window_translate.x = this.window_translate.x - (coords.x - current.x.min) * this.window_scale this.go_to(this.window_scale, this.window_translate) } else if (coords.x > current.x.max) { this.window_translate.x = this.window_translate.x - (coords.x - current.x.max) * this.window_scale this.go_to(this.window_scale, this.window_translate) } if (coords.y < current.y.min) { this.window_translate.y = this.window_translate.y - (coords.y - current.y.min) * this.window_scale this.go_to(this.window_scale, this.window_translate) } else if (coords.y > current.y.max) { this.window_translate.y = this.window_translate.y - (coords.y - current.y.max) * this.window_scale this.go_to(this.window_scale, this.window_translate) } }