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
|
|
*/
|
|
class Canvas {
|
|
/**
|
|
* @param {Object} body
|
|
* @constructor Canvas
|
|
*/
|
|
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
|
|
* @static
|
|
*/
|
|
_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 "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 "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;
|