let util = require('../../util'); import NavigationHandler from './components/NavigationHandler' import Popup from './components/Popup' class InteractionHandler { constructor(body, canvas, selectionHandler) { this.body = body; this.canvas = canvas; this.selectionHandler = selectionHandler; this.navigationHandler = new NavigationHandler(body,canvas); // bind the events from hammer to functions in this object this.body.eventListeners.onTap = this.onTap.bind(this); this.body.eventListeners.onTouch = this.onTouch.bind(this); this.body.eventListeners.onDoubleTap = this.onDoubleTap.bind(this); this.body.eventListeners.onHold = this.onHold.bind(this); this.body.eventListeners.onDragStart = this.onDragStart.bind(this); this.body.eventListeners.onDrag = this.onDrag.bind(this); this.body.eventListeners.onDragEnd = this.onDragEnd.bind(this); this.body.eventListeners.onMouseWheel = this.onMouseWheel.bind(this); this.body.eventListeners.onPinch = this.onPinch.bind(this); this.body.eventListeners.onMouseMove = this.onMouseMove.bind(this); this.body.eventListeners.onRelease = this.onRelease.bind(this); this.body.eventListeners.onContext = this.onContext.bind(this); this.touchTime = 0; this.drag = {}; this.pinch = {}; this.popup = undefined; this.popupObj = undefined; this.popupTimer = undefined; this.body.functions.getPointer = this.getPointer.bind(this); this.options = {}; this.defaultOptions = { dragNodes:true, dragView: true, hover: false, keyboard: { enabled: false, speed: {x: 10, y: 10, zoom: 0.02}, bindToWindow: true }, navigationButtons: false, tooltipDelay: 300, zoomView: true }; util.extend(this.options,this.defaultOptions); this.bindEventListeners() } bindEventListeners() { this.body.emitter.on('destroy', () => { clearTimeout(this.popupTimer); delete this.body.functions.getPointer; }) } setOptions(options) { if (options !== undefined) { // extend all but the values in fields let fields = ['hideEdgesOnDrag','hideNodesOnDrag','keyboard','multiselect','selectable','selectConnectedEdges']; util.selectiveNotDeepExtend(fields, this.options, options); // merge the keyboard options in. util.mergeOptions(this.options, options, 'keyboard'); if (options.tooltip) { util.extend(this.options.tooltip, options.tooltip); if (options.tooltip.color) { this.options.tooltip.color = util.parseColor(options.tooltip.color); } } } this.navigationHandler.setOptions(this.options); } /** * Get the pointer location from a touch location * @param {{x: Number, y: Number}} touch * @return {{x: Number, y: Number}} pointer * @private */ getPointer(touch) { return { x: touch.x - util.getAbsoluteLeft(this.canvas.frame.canvas), y: touch.y - util.getAbsoluteTop(this.canvas.frame.canvas) }; } /** * On start of a touch gesture, store the pointer * @param event * @private */ onTouch(event) { if (new Date().valueOf() - this.touchTime > 50) { this.drag.pointer = this.getPointer(event.center); this.drag.pinched = false; this.pinch.scale = this.body.view.scale; // to avoid double fireing of this event because we have two hammer instances. (on canvas and on frame) this.touchTime = new Date().valueOf(); } } /** * handle tap/click event: select/unselect a node * @private */ onTap(event) { let pointer = this.getPointer(event.center); let multiselect = this.selectionHandler.options.multiselect && (event.changedPointers[0].ctrlKey || event.changedPointers[0].metaKey); this.checkSelectionChanges(pointer, event, multiselect); this.selectionHandler._generateClickEvent('click', event, pointer); } /** * handle doubletap event * @private */ onDoubleTap(event) { let pointer = this.getPointer(event.center); this.selectionHandler._generateClickEvent('doubleClick', event, pointer); } /** * handle long tap event: multi select nodes * @private */ onHold(event) { let pointer = this.getPointer(event.center); let multiselect = this.selectionHandler.options.multiselect; this.checkSelectionChanges(pointer, event, multiselect); this.selectionHandler._generateClickEvent('click', event, pointer); this.selectionHandler._generateClickEvent('hold', event, pointer); } /** * handle the release of the screen * * @private */ onRelease(event) { if (new Date().valueOf() - this.touchTime > 10) { let pointer = this.getPointer(event.center); this.selectionHandler._generateClickEvent('release', event, pointer); // to avoid double fireing of this event because we have two hammer instances. (on canvas and on frame) this.touchTime = new Date().valueOf(); } } onContext(event) { let pointer = this.getPointer({x:event.clientX, y:event.clientY}); this.selectionHandler._generateClickEvent('oncontext', event, pointer); } /** * * @param pointer * @param add */ checkSelectionChanges(pointer, event, add = false) { let previouslySelectedEdgeCount = this.selectionHandler._getSelectedEdgeCount(); let previouslySelectedNodeCount = this.selectionHandler._getSelectedNodeCount(); let previousSelection = this.selectionHandler.getSelection(); let selected; if (add === true) { selected = this.selectionHandler.selectAdditionalOnPoint(pointer); } else { selected = this.selectionHandler.selectOnPoint(pointer); } let selectedEdgesCount = this.selectionHandler._getSelectedEdgeCount(); let selectedNodesCount = this.selectionHandler._getSelectedNodeCount(); let currentSelection = this.selectionHandler.getSelection(); let {nodesChanges, edgesChanges} = this._determineIfDifferent(previousSelection, currentSelection); if (selectedNodesCount - previouslySelectedNodeCount > 0) { // node was selected this.selectionHandler._generateClickEvent('selectNode', event, pointer); selected = true; } else if (selectedNodesCount - previouslySelectedNodeCount < 0) { // node was deselected this.selectionHandler._generateClickEvent('deselectNode', event, pointer, previousSelection); selected = true; } else if (selectedNodesCount === previouslySelectedNodeCount && nodesChanges === true) { this.selectionHandler._generateClickEvent('deselectNode', event, pointer, previousSelection); this.selectionHandler._generateClickEvent('selectNode', event, pointer); selected = true; } if (selectedEdgesCount - previouslySelectedEdgeCount > 0) { // edge was selected this.selectionHandler._generateClickEvent('selectEdge', event, pointer); selected = true; } else if (selectedEdgesCount - previouslySelectedEdgeCount < 0) { // edge was deselected this.selectionHandler._generateClickEvent('deselectEdge', event, pointer, previousSelection); selected = true; } else if (selectedEdgesCount === previouslySelectedEdgeCount && edgesChanges === true) { this.selectionHandler._generateClickEvent('deselectEdge', event, pointer, previousSelection); this.selectionHandler._generateClickEvent('selectEdge', event, pointer); selected = true; } if (selected === true) { // select or unselect this.selectionHandler._generateClickEvent('select', event, pointer); } } /** * This function checks if the nodes and edges previously selected have changed. * @param previousSelection * @param currentSelection * @returns {{nodesChanges: boolean, edgesChanges: boolean}} * @private */ _determineIfDifferent(previousSelection,currentSelection) { let nodesChanges = false; let edgesChanges = false; for (let i = 0; i < previousSelection.nodes.length; i++) { if (currentSelection.nodes.indexOf(previousSelection.nodes[i]) === -1) { nodesChanges = true; } } for (let i = 0; i < currentSelection.nodes.length; i++) { if (previousSelection.nodes.indexOf(previousSelection.nodes[i]) === -1) { nodesChanges = true; } } for (let i = 0; i < previousSelection.edges.length; i++) { if (currentSelection.edges.indexOf(previousSelection.edges[i]) === -1) { edgesChanges = true; } } for (let i = 0; i < currentSelection.edges.length; i++) { if (previousSelection.edges.indexOf(previousSelection.edges[i]) === -1) { edgesChanges = true; } } return {nodesChanges, edgesChanges}; } /** * This function is called by onDragStart. * It is separated out because we can then overload it for the datamanipulation system. * * @private */ onDragStart(event) { //in case the touch event was triggered on an external div, do the initial touch now. if (this.drag.pointer === undefined) { this.onTouch(event); } // note: drag.pointer is set in onTouch to get the initial touch location let node = this.selectionHandler.getNodeAt(this.drag.pointer); this.drag.dragging = true; this.drag.selection = []; this.drag.translation = util.extend({},this.body.view.translation); // copy the object this.drag.nodeId = undefined; if (node !== undefined && this.options.dragNodes === true) { this.drag.nodeId = node.id; // select the clicked node if not yet selected if (node.isSelected() === false) { this.selectionHandler.unselectAll(); this.selectionHandler.selectObject(node); } // after select to contain the node this.selectionHandler._generateClickEvent('dragStart', event, this.drag.pointer); let selection = this.selectionHandler.selectionObj.nodes; // create an array with the selected nodes and their original location and status for (let nodeId in selection) { if (selection.hasOwnProperty(nodeId)) { let object = selection[nodeId]; let s = { id: object.id, node: object, // store original x, y, xFixed and yFixed, make the node temporarily Fixed x: object.x, y: object.y, xFixed: object.options.fixed.x, yFixed: object.options.fixed.y }; object.options.fixed.x = true; object.options.fixed.y = true; this.drag.selection.push(s); } } } else { // fallback if no node is selected and thus the view is dragged. this.selectionHandler._generateClickEvent('dragStart', event, this.drag.pointer, undefined, true); } } /** * handle drag event * @private */ onDrag(event) { if (this.drag.pinched === true) { return; } // remove the focus on node if it is focussed on by the focusOnNode this.body.emitter.emit('unlockNode'); let pointer = this.getPointer(event.center); let selection = this.drag.selection; if (selection && selection.length && this.options.dragNodes === true) { this.selectionHandler._generateClickEvent('dragging', event, pointer); // calculate delta's and new location let deltaX = pointer.x - this.drag.pointer.x; let deltaY = pointer.y - this.drag.pointer.y; // update position of all selected nodes selection.forEach((selection) => { let node = selection.node; // only move the node if it was not fixed initially if (selection.xFixed === false) { node.x = this.canvas._XconvertDOMtoCanvas(this.canvas._XconvertCanvasToDOM(selection.x) + deltaX); } // only move the node if it was not fixed initially if (selection.yFixed === false) { node.y = this.canvas._YconvertDOMtoCanvas(this.canvas._YconvertCanvasToDOM(selection.y) + deltaY); } }); // start the simulation of the physics this.body.emitter.emit('startSimulation'); } else { // move the network if (this.options.dragView === true) { this.selectionHandler._generateClickEvent('dragging', event, pointer, undefined, true); // if the drag was not started properly because the click started outside the network div, start it now. if (this.drag.pointer === undefined) { this._handleDragStart(event); return; } let diffX = pointer.x - this.drag.pointer.x; let diffY = pointer.y - this.drag.pointer.y; this.body.view.translation = {x:this.drag.translation.x + diffX, y:this.drag.translation.y + diffY}; this.body.emitter.emit('_redraw'); } } } /** * handle drag start event * @private */ onDragEnd(event) { this.drag.dragging = false; let selection = this.drag.selection; if (selection && selection.length) { selection.forEach(function (s) { // restore original xFixed and yFixed s.node.options.fixed.x = s.xFixed; s.node.options.fixed.y = s.yFixed; }); this.selectionHandler._generateClickEvent('dragEnd', event, this.getPointer(event.center)); this.body.emitter.emit('startSimulation'); } else { this.selectionHandler._generateClickEvent('dragEnd', event, this.getPointer(event.center), undefined, true); this.body.emitter.emit('_requestRedraw'); } } /** * Handle pinch event * @param event * @private */ onPinch(event) { let pointer = this.getPointer(event.center); this.drag.pinched = true; if (this.pinch['scale'] === undefined) { this.pinch.scale = 1; } // TODO: enabled moving while pinching? let scale = this.pinch.scale * event.scale; this.zoom(scale, pointer) } /** * Zoom the network in or out * @param {Number} scale a number around 1, and between 0.01 and 10 * @param {{x: Number, y: Number}} pointer Position on screen * @return {Number} appliedScale scale is limited within the boundaries * @private */ zoom(scale, pointer) { if (this.options.zoomView === true) { let scaleOld = this.body.view.scale; if (scale < 0.00001) { scale = 0.00001; } if (scale > 10) { scale = 10; } let preScaleDragPointer = undefined; if (this.drag !== undefined) { if (this.drag.dragging === true) { preScaleDragPointer = this.canvas.DOMtoCanvas(this.drag.pointer); } } // + this.canvas.frame.canvas.clientHeight / 2 let translation = this.body.view.translation; let scaleFrac = scale / scaleOld; let tx = (1 - scaleFrac) * pointer.x + translation.x * scaleFrac; let ty = (1 - scaleFrac) * pointer.y + translation.y * scaleFrac; this.body.view.scale = scale; this.body.view.translation = {x:tx, y:ty}; if (preScaleDragPointer != undefined) { let postScaleDragPointer = this.canvas.canvasToDOM(preScaleDragPointer); this.drag.pointer.x = postScaleDragPointer.x; this.drag.pointer.y = postScaleDragPointer.y; } this.body.emitter.emit('_requestRedraw'); if (scaleOld < scale) { this.body.emitter.emit('zoom', {direction: '+', scale: this.body.view.scale}); } else { this.body.emitter.emit('zoom', {direction: '-', scale: this.body.view.scale}); } } } /** * Event handler for mouse wheel event, used to zoom the timeline * See http://adomas.org/javascript-mouse-wheel/ * https://github.com/EightMedia/hammer.js/issues/256 * @param {MouseEvent} event * @private */ onMouseWheel(event) { // retrieve delta let delta = 0; if (event.wheelDelta) { /* IE/Opera. */ delta = event.wheelDelta / 120; } else if (event.detail) { /* Mozilla case. */ // In Mozilla, sign of delta is different than in IE. // Also, delta is multiple of 3. delta = -event.detail / 3; } // If delta is nonzero, handle it. // Basically, delta is now positive if wheel was scrolled up, // and negative, if wheel was scrolled down. if (delta !== 0) { // calculate the new scale let scale = this.body.view.scale; let zoom = delta / 10; if (delta < 0) { zoom = zoom / (1 - zoom); } scale *= (1 + zoom); // calculate the pointer location let pointer = this.getPointer({x:event.clientX, y:event.clientY}); // apply the new scale this.zoom(scale, pointer); } // Prevent default actions caused by mouse wheel. event.preventDefault(); } /** * Mouse move handler for checking whether the title moves over a node with a title. * @param {Event} event * @private */ onMouseMove(event) { let pointer = this.getPointer({x:event.clientX, y:event.clientY}); let popupVisible = false; // check if the previously selected node is still selected if (this.popup !== undefined) { if (this.popup.hidden === false) { this._checkHidePopup(pointer); } // if the popup was not hidden above if (this.popup.hidden === false) { popupVisible = true; this.popup.setPosition(pointer.x + 3, pointer.y - 5); this.popup.show(); } } // if we bind the keyboard to the div, we have to highlight it to use it. This highlights it on mouse over. if (this.options.keyboard.bindToWindow === false && this.options.keyboard.enabled === true) { this.canvas.frame.focus(); } // start a timeout that will check if the mouse is positioned above an element if (popupVisible === false) { if (this.popupTimer !== undefined) { clearInterval(this.popupTimer); // stop any running calculationTimer this.popupTimer = undefined; } if (!this.drag.dragging) { this.popupTimer = setTimeout(() => this._checkShowPopup(pointer), this.options.tooltipDelay); } } /** * Adding hover highlights */ if (this.options.hover === true) { // adding hover highlights let obj = this.selectionHandler.getNodeAt(pointer); if (obj === undefined) { obj = this.selectionHandler.getEdgeAt(pointer); } this.selectionHandler.hoverObject(obj); } } /** * Check if there is an element on the given position in the network * (a node or edge). If so, and if this element has a title, * show a popup window with its title. * * @param {{x:Number, y:Number}} pointer * @private */ _checkShowPopup(pointer) { let x = this.canvas._XconvertDOMtoCanvas(pointer.x); let y = this.canvas._YconvertDOMtoCanvas(pointer.y); let pointerObj = { left: x, top: y, right: x, bottom: y }; let previousPopupObjId = this.popupObj === undefined ? undefined : this.popupObj.id; let nodeUnderCursor = false; let popupType = 'node'; // check if a node is under the cursor. if (this.popupObj === undefined) { // search the nodes for overlap, select the top one in case of multiple nodes let nodeIndices = this.body.nodeIndices; let nodes = this.body.nodes; let node; let overlappingNodes = []; for (let i = 0; i < nodeIndices.length; i++) { node = nodes[nodeIndices[i]]; if (node.isOverlappingWith(pointerObj) === true) { if (node.getTitle() !== undefined) { overlappingNodes.push(nodeIndices[i]); } } } if (overlappingNodes.length > 0) { // if there are overlapping nodes, select the last one, this is the one which is drawn on top of the others this.popupObj = nodes[overlappingNodes[overlappingNodes.length - 1]]; // if you hover over a node, the title of the edge is not supposed to be shown. nodeUnderCursor = true; } } if (this.popupObj === undefined && nodeUnderCursor === false) { // search the edges for overlap let edgeIndices = this.body.edgeIndices; let edges = this.body.edges; let edge; let overlappingEdges = []; for (let i = 0; i < edgeIndices.length; i++) { edge = edges[edgeIndices[i]]; if (edge.isOverlappingWith(pointerObj) === true) { if (edge.connected === true && edge.getTitle() !== undefined) { overlappingEdges.push(edgeIndices[i]); } } } if (overlappingEdges.length > 0) { this.popupObj = edges[overlappingEdges[overlappingEdges.length - 1]]; popupType = 'edge'; } } if (this.popupObj !== undefined) { // show popup message window if (this.popupObj.id !== previousPopupObjId) { if (this.popup === undefined) { this.popup = new Popup(this.canvas.frame); } this.popup.popupTargetType = popupType; this.popup.popupTargetId = this.popupObj.id; // adjust a small offset such that the mouse cursor is located in the // bottom left location of the popup, and you can easily move over the // popup area this.popup.setPosition(pointer.x + 3, pointer.y - 5); this.popup.setText(this.popupObj.getTitle()); this.popup.show(); this.body.emitter.emit('showPopup',this.popupObj.id); } } else { if (this.popup !== undefined) { this.popup.hide(); this.body.emitter.emit('hidePopup'); } } } /** * Check if the popup must be hidden, which is the case when the mouse is no * longer hovering on the object * @param {{x:Number, y:Number}} pointer * @private */ _checkHidePopup(pointer) { let pointerObj = this.selectionHandler._pointerToPositionObject(pointer); let stillOnObj = false; if (this.popup.popupTargetType === 'node') { if (this.body.nodes[this.popup.popupTargetId] !== undefined) { stillOnObj = this.body.nodes[this.popup.popupTargetId].isOverlappingWith(pointerObj); // if the mouse is still one the node, we have to check if it is not also on one that is drawn on top of it. // we initially only check stillOnObj because this is much faster. if (stillOnObj === true) { let overNode = this.selectionHandler.getNodeAt(pointer); stillOnObj = overNode.id === this.popup.popupTargetId; } } } else { if (this.selectionHandler.getNodeAt(pointer) === undefined) { if (this.body.edges[this.popup.popupTargetId] !== undefined) { stillOnObj = this.body.edges[this.popup.popupTargetId].isOverlappingWith(pointerObj); } } } if (stillOnObj === false) { this.popupObj = undefined; this.popup.hide(); this.body.emitter.emit('hidePopup'); } } } export default InteractionHandler;