UNPKG

visjs-network

Version:

A dynamic, browser-based network visualization library.

531 lines (470 loc) 15.4 kB
let Hammer = require('../../module/hammer') let hammerUtil = require('../../hammerUtil') let util = require('../../util') /** * Create the main frame for the Network. * This function is executed once when a Network object is created. The frame * contains a canvas, and this canvas contains all objects like the axis and * nodes. */ class Canvas { /** * @param {Object} body */ constructor(body) { this.body = body this.pixelRatio = 1 this.resizeTimer = undefined this.resizeFunction = this._onResize.bind(this) this.cameraState = {} this.initialized = false this.canvasViewCenter = {} this.options = {} this.defaultOptions = { autoResize: true, height: '100%', width: '100%' } util.extend(this.options, this.defaultOptions) this.bindEventListeners() } /** * Binds event listeners */ bindEventListeners() { // bind the events this.body.emitter.once('resize', obj => { if (obj.width !== 0) { this.body.view.translation.x = obj.width * 0.5 } if (obj.height !== 0) { this.body.view.translation.y = obj.height * 0.5 } }) this.body.emitter.on('setSize', this.setSize.bind(this)) this.body.emitter.on('destroy', () => { this.hammerFrame.destroy() this.hammer.destroy() this._cleanUp() }) } /** * @param {Object} options */ setOptions(options) { if (options !== undefined) { let fields = ['width', 'height', 'autoResize'] util.selectiveDeepExtend(fields, this.options, options) } if (this.options.autoResize === true) { // automatically adapt to a changing size of the browser. this._cleanUp() this.resizeTimer = setInterval(() => { let changed = this.setSize() if (changed === true) { this.body.emitter.emit('_requestRedraw') } }, 1000) this.resizeFunction = this._onResize.bind(this) util.addEventListener(window, 'resize', this.resizeFunction) } } /** * @private */ _cleanUp() { // automatically adapt to a changing size of the browser. if (this.resizeTimer !== undefined) { clearInterval(this.resizeTimer) } util.removeEventListener(window, 'resize', this.resizeFunction) this.resizeFunction = undefined } /** * @private */ _onResize() { this.setSize() this.body.emitter.emit('_redraw') } /** * Get and store the cameraState * * @param {number} [pixelRatio=this.pixelRatio] * @private */ _getCameraState(pixelRatio = this.pixelRatio) { if (this.initialized === true) { this.cameraState.previousWidth = this.frame.canvas.width / pixelRatio this.cameraState.previousHeight = this.frame.canvas.height / pixelRatio this.cameraState.scale = this.body.view.scale this.cameraState.position = this.DOMtoCanvas({ x: (0.5 * this.frame.canvas.width) / pixelRatio, y: (0.5 * this.frame.canvas.height) / pixelRatio }) } } /** * Set the cameraState * @private */ _setCameraState() { if ( this.cameraState.scale !== undefined && this.frame.canvas.clientWidth !== 0 && this.frame.canvas.clientHeight !== 0 && this.pixelRatio !== 0 && this.cameraState.previousWidth > 0 ) { let widthRatio = this.frame.canvas.width / this.pixelRatio / this.cameraState.previousWidth let heightRatio = this.frame.canvas.height / this.pixelRatio / this.cameraState.previousHeight let newScale = this.cameraState.scale if (widthRatio != 1 && heightRatio != 1) { newScale = this.cameraState.scale * 0.5 * (widthRatio + heightRatio) } else if (widthRatio != 1) { newScale = this.cameraState.scale * widthRatio } else if (heightRatio != 1) { newScale = this.cameraState.scale * heightRatio } this.body.view.scale = newScale // this comes from the view module. var currentViewCenter = this.DOMtoCanvas({ x: 0.5 * this.frame.canvas.clientWidth, y: 0.5 * this.frame.canvas.clientHeight }) var distanceFromCenter = { // offset from view, distance view has to change by these x and y to center the node x: currentViewCenter.x - this.cameraState.position.x, y: currentViewCenter.y - this.cameraState.position.y } this.body.view.translation.x += distanceFromCenter.x * this.body.view.scale this.body.view.translation.y += distanceFromCenter.y * this.body.view.scale } } /** * * @param {number|string} value * @returns {string} * @private */ _prepareValue(value) { if (typeof value === 'number') { return value + 'px' } else if (typeof value === 'string') { if (value.indexOf('%') !== -1 || value.indexOf('px') !== -1) { return value } else if (value.indexOf('%') === -1) { return value + 'px' } } throw new Error( 'Could not use the value supplied for width or height:' + value ) } /** * Create the HTML */ _create() { // remove all elements from the container element. while (this.body.container.hasChildNodes()) { this.body.container.removeChild(this.body.container.firstChild) } this.frame = document.createElement('div') this.frame.className = 'vis-network' this.frame.style.position = 'relative' this.frame.style.overflow = 'hidden' this.frame.tabIndex = 900 // tab index is required for keycharm to bind keystrokes to the div instead of the window ////////////////////////////////////////////////////////////////// this.frame.canvas = document.createElement('canvas') this.frame.canvas.style.position = 'relative' this.frame.appendChild(this.frame.canvas) if (!this.frame.canvas.getContext) { let noCanvas = document.createElement('DIV') noCanvas.style.color = 'red' noCanvas.style.fontWeight = 'bold' noCanvas.style.padding = '10px' noCanvas.innerHTML = 'Error: your browser does not support HTML canvas' this.frame.canvas.appendChild(noCanvas) } else { this._setPixelRatio() this.setTransform() } // add the frame to the container element this.body.container.appendChild(this.frame) this.body.view.scale = 1 this.body.view.translation = { x: 0.5 * this.frame.canvas.clientWidth, y: 0.5 * this.frame.canvas.clientHeight } this._bindHammer() } /** * This function binds hammer, it can be repeated over and over due to the uniqueness check. * @private */ _bindHammer() { if (this.hammer !== undefined) { this.hammer.destroy() } this.drag = {} this.pinch = {} // init hammer this.hammer = new Hammer(this.frame.canvas) this.hammer.get('pinch').set({ enable: true }) // enable to get better response, todo: test on mobile. this.hammer .get('pan') .set({ threshold: 5, direction: Hammer.DIRECTION_ALL }) hammerUtil.onTouch(this.hammer, event => { this.body.eventListeners.onTouch(event) }) this.hammer.on('tap', event => { this.body.eventListeners.onTap(event) }) this.hammer.on('doubletap', event => { this.body.eventListeners.onDoubleTap(event) }) this.hammer.on('press', event => { this.body.eventListeners.onHold(event) }) this.hammer.on('panstart', event => { this.body.eventListeners.onDragStart(event) }) this.hammer.on('panmove', event => { this.body.eventListeners.onDrag(event) }) this.hammer.on('panend', event => { this.body.eventListeners.onDragEnd(event) }) this.hammer.on('pinch', event => { this.body.eventListeners.onPinch(event) }) // TODO: neatly cleanup these handlers when re-creating the Canvas, IF these are done with hammer, event.stopPropagation will not work? this.frame.canvas.addEventListener('mousewheel', event => { this.body.eventListeners.onMouseWheel(event) }) this.frame.canvas.addEventListener('DOMMouseScroll', event => { this.body.eventListeners.onMouseWheel(event) }) this.frame.canvas.addEventListener('mousemove', event => { this.body.eventListeners.onMouseMove(event) }) this.frame.canvas.addEventListener('contextmenu', event => { this.body.eventListeners.onContext(event) }) this.hammerFrame = new Hammer(this.frame) hammerUtil.onRelease(this.hammerFrame, event => { this.body.eventListeners.onRelease(event) }) } /** * Set a new size for the network * @param {string} width Width in pixels or percentage (for example '800px' * or '50%') * @param {string} height Height in pixels or percentage (for example '400px' * or '30%') * @returns {boolean} */ setSize(width = this.options.width, height = this.options.height) { width = this._prepareValue(width) height = this._prepareValue(height) let emitEvent = false let oldWidth = this.frame.canvas.width let oldHeight = this.frame.canvas.height // update the pixel ratio // // NOTE: Comment in following is rather inconsistent; this is the ONLY place in the code // where it is assumed that the pixel ratio could change at runtime. // The only way I can think of this happening is a rotating screen or tablet; but then // there should be a mechanism for reloading the data (TODO: check if this is present). // // If the assumption is true (i.e. pixel ratio can change at runtime), then *all* usage // of pixel ratio must be overhauled for this. // // For the time being, I will humor the assumption here, and in the rest of the code assume it is // constant. let previousRatio = this.pixelRatio // we cache this because the camera state storage needs the old value this._setPixelRatio() if ( width != this.options.width || height != this.options.height || this.frame.style.width != width || this.frame.style.height != height ) { this._getCameraState(previousRatio) this.frame.style.width = width this.frame.style.height = height this.frame.canvas.style.width = '100%' this.frame.canvas.style.height = '100%' this.frame.canvas.width = Math.round( this.frame.canvas.clientWidth * this.pixelRatio ) this.frame.canvas.height = Math.round( this.frame.canvas.clientHeight * this.pixelRatio ) this.options.width = width this.options.height = height this.canvasViewCenter = { x: 0.5 * this.frame.clientWidth, y: 0.5 * this.frame.clientHeight } emitEvent = true } else { // this would adapt the width of the canvas to the width from 100% if and only if // there is a change. let newWidth = Math.round(this.frame.canvas.clientWidth * this.pixelRatio) let newHeight = Math.round( this.frame.canvas.clientHeight * this.pixelRatio ) // store the camera if there is a change in size. if ( this.frame.canvas.width !== newWidth || this.frame.canvas.height !== newHeight ) { this._getCameraState(previousRatio) } if (this.frame.canvas.width !== newWidth) { this.frame.canvas.width = newWidth emitEvent = true } if (this.frame.canvas.height !== newHeight) { this.frame.canvas.height = newHeight emitEvent = true } } if (emitEvent === true) { this.body.emitter.emit('resize', { width: Math.round(this.frame.canvas.width / this.pixelRatio), height: Math.round(this.frame.canvas.height / this.pixelRatio), oldWidth: Math.round(oldWidth / this.pixelRatio), oldHeight: Math.round(oldHeight / this.pixelRatio) }) // restore the camera on change. this._setCameraState() } // set initialized so the get and set camera will work from now on. this.initialized = true return emitEvent } /** * * @returns {CanvasRenderingContext2D} */ getContext() { return this.frame.canvas.getContext('2d') } /** * Determine the pixel ratio for various browsers. * * @returns {number} * @private */ _determinePixelRatio() { let ctx = this.getContext() if (ctx === undefined) { throw new Error('Could not get canvax context') } var numerator = 1 if (typeof window !== 'undefined') { // (window !== undefined) doesn't work here! // Protection during unit tests, where 'window' can be missing numerator = window.devicePixelRatio || 1 } var denominator = ctx.webkitBackingStorePixelRatio || ctx.mozBackingStorePixelRatio || ctx.msBackingStorePixelRatio || ctx.oBackingStorePixelRatio || ctx.backingStorePixelRatio || 1 return numerator / denominator } /** * Lazy determination of pixel ratio. * * @private */ _setPixelRatio() { this.pixelRatio = this._determinePixelRatio() } /** * Set the transform in the contained context, based on its pixelRatio */ setTransform() { let ctx = this.getContext() if (ctx === undefined) { throw new Error('Could not get canvax context') } ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0) } /** * Convert the X coordinate in DOM-space (coordinate point in browser relative to the container div) to * the X coordinate in canvas-space (the simulation sandbox, which the camera looks upon) * @param {number} x * @returns {number} * @private */ _XconvertDOMtoCanvas(x) { return (x - this.body.view.translation.x) / this.body.view.scale } /** * Convert the X coordinate in canvas-space (the simulation sandbox, which the camera looks upon) to * the X coordinate in DOM-space (coordinate point in browser relative to the container div) * @param {number} x * @returns {number} * @private */ _XconvertCanvasToDOM(x) { return x * this.body.view.scale + this.body.view.translation.x } /** * Convert the Y coordinate in DOM-space (coordinate point in browser relative to the container div) to * the Y coordinate in canvas-space (the simulation sandbox, which the camera looks upon) * @param {number} y * @returns {number} * @private */ _YconvertDOMtoCanvas(y) { return (y - this.body.view.translation.y) / this.body.view.scale } /** * Convert the Y coordinate in canvas-space (the simulation sandbox, which the camera looks upon) to * the Y coordinate in DOM-space (coordinate point in browser relative to the container div) * @param {number} y * @returns {number} * @private */ _YconvertCanvasToDOM(y) { return y * this.body.view.scale + this.body.view.translation.y } /** * @param {point} pos * @returns {point} */ canvasToDOM(pos) { return { x: this._XconvertCanvasToDOM(pos.x), y: this._YconvertCanvasToDOM(pos.y) } } /** * * @param {point} pos * @returns {point} */ DOMtoCanvas(pos) { return { x: this._XconvertDOMtoCanvas(pos.x), y: this._YconvertDOMtoCanvas(pos.y) } } } export default Canvas