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"); } return (window.devicePixelRatio || 1) / (ctx.webkitBackingStorePixelRatio || ctx.mozBackingStorePixelRatio || ctx.msBackingStorePixelRatio || ctx.oBackingStorePixelRatio || ctx.backingStorePixelRatio || 1); } /** * 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 {object} pos = {x: number, y: number} * @returns {{x: number, y: number}} * @constructor */ canvasToDOM (pos) { return {x: this._XconvertCanvasToDOM(pos.x), y: this._YconvertCanvasToDOM(pos.y)}; } /** * * @param {object} pos = {x: number, y: number} * @returns {{x: number, y: number}} * @constructor */ DOMtoCanvas (pos) { return {x: this._XconvertDOMtoCanvas(pos.x), y: this._YconvertDOMtoCanvas(pos.y)}; } } export default Canvas;