visjs-network
Version:
A dynamic, browser-based network visualization library.
865 lines (791 loc) • 23.8 kB
JavaScript
var Node = require('./components/Node').default
var Edge = require('./components/Edge').default
let util = require('../../util')
/**
* The handler for selections
*/
class SelectionHandler {
/**
* @param {Object} body
* @param {Canvas} canvas
*/
constructor(body, canvas) {
this.body = body
this.canvas = canvas
this.selectionObj = { nodes: [], edges: [] }
this.hoverObj = { nodes: {}, edges: {} }
this.options = {}
this.defaultOptions = {
multiselect: false,
selectable: true,
selectConnectedEdges: true,
hoverConnectedEdges: true
}
util.extend(this.options, this.defaultOptions)
this.body.emitter.on('_dataChanged', () => {
this.updateSelection()
})
}
/**
*
* @param {Object} [options]
*/
setOptions(options) {
if (options !== undefined) {
let fields = [
'multiselect',
'hoverConnectedEdges',
'selectable',
'selectConnectedEdges'
]
util.selectiveDeepExtend(fields, this.options, options)
}
}
/**
* handles the selection part of the tap;
*
* @param {{x: number, y: number}} pointer
* @returns {boolean}
*/
selectOnPoint(pointer) {
let selected = false
if (this.options.selectable === true) {
let obj = this.getNodeAt(pointer) || this.getEdgeAt(pointer)
// unselect after getting the objects in order to restore width and height.
this.unselectAll()
if (obj !== undefined) {
selected = this.selectObject(obj)
}
this.body.emitter.emit('_requestRedraw')
}
return selected
}
/**
*
* @param {{x: number, y: number}} pointer
* @returns {boolean}
*/
selectAdditionalOnPoint(pointer) {
let selectionChanged = false
if (this.options.selectable === true) {
let obj = this.getNodeAt(pointer) || this.getEdgeAt(pointer)
if (obj !== undefined) {
selectionChanged = true
if (obj.isSelected() === true) {
this.deselectObject(obj)
} else {
this.selectObject(obj)
}
this.body.emitter.emit('_requestRedraw')
}
}
return selectionChanged
}
/**
* Create an object containing the standard fields for an event.
*
* @param {Event} event
* @param {{x: number, y: number}} pointer Object with the x and y screen coordinates of the mouse
* @returns {{}}
* @private
*/
_initBaseEvent(event, pointer) {
let properties = {}
properties['pointer'] = {
DOM: { x: pointer.x, y: pointer.y },
canvas: this.canvas.DOMtoCanvas(pointer)
}
properties['event'] = event
return properties
}
/**
* Generate an event which the user can catch.
*
* This adds some extra data to the event with respect to cursor position and
* selected nodes and edges.
*
* @param {string} eventType Name of event to send
* @param {Event} event
* @param {{x: number, y: number}} pointer Object with the x and y screen coordinates of the mouse
* @param {Object|undefined} oldSelection If present, selection state before event occured
* @param {boolean|undefined} [emptySelection=false] Indicate if selection data should be passed
*/
_generateClickEvent(
eventType,
event,
pointer,
oldSelection,
emptySelection = false
) {
let properties = this._initBaseEvent(event, pointer)
if (emptySelection === true) {
properties.nodes = []
properties.edges = []
} else {
let tmp = this.getSelection()
properties.nodes = tmp.nodes
properties.edges = tmp.edges
}
if (oldSelection !== undefined) {
properties['previousSelection'] = oldSelection
}
if (eventType == 'click') {
// For the time being, restrict this functionality to
// just the click event.
properties.items = this.getClickedItems(pointer)
}
if (event.controlEdge !== undefined) {
properties.controlEdge = event.controlEdge
}
this.body.emitter.emit(eventType, properties)
}
/**
*
* @param {Object} obj
* @param {boolean} [highlightEdges=this.options.selectConnectedEdges]
* @returns {boolean}
*/
selectObject(obj, highlightEdges = this.options.selectConnectedEdges) {
if (obj !== undefined) {
if (obj instanceof Node) {
if (highlightEdges === true) {
this._selectConnectedEdges(obj)
}
}
obj.select()
this._addToSelection(obj)
return true
}
return false
}
/**
*
* @param {Object} obj
*/
deselectObject(obj) {
if (obj.isSelected() === true) {
obj.selected = false
this._removeFromSelection(obj)
}
}
/**
* retrieve all nodes overlapping with given object
* @param {Object} object An object with parameters left, top, right, bottom
* @return {number[]} An array with id's of the overlapping nodes
* @private
*/
_getAllNodesOverlappingWith(object) {
let overlappingNodes = []
let nodes = this.body.nodes
for (let i = 0; i < this.body.nodeIndices.length; i++) {
let nodeId = this.body.nodeIndices[i]
if (nodes[nodeId].isOverlappingWith(object)) {
overlappingNodes.push(nodeId)
}
}
return overlappingNodes
}
/**
* Return a position object in canvasspace from a single point in screenspace
*
* @param {{x: number, y: number}} pointer
* @returns {{left: number, top: number, right: number, bottom: number}}
* @private
*/
_pointerToPositionObject(pointer) {
let canvasPos = this.canvas.DOMtoCanvas(pointer)
return {
left: canvasPos.x - 1,
top: canvasPos.y + 1,
right: canvasPos.x + 1,
bottom: canvasPos.y - 1
}
}
/**
* Get the top node at the passed point (like a click)
*
* @param {{x: number, y: number}} pointer
* @param {boolean} [returnNode=true]
* @return {Node | undefined} node
*/
getNodeAt(pointer, returnNode = true) {
// we first check if this is an navigation controls element
let positionObject = this._pointerToPositionObject(pointer)
let overlappingNodes = this._getAllNodesOverlappingWith(positionObject)
// if there are overlapping nodes, select the last one, this is the
// one which is drawn on top of the others
if (overlappingNodes.length > 0) {
if (returnNode === true) {
return this.body.nodes[overlappingNodes[overlappingNodes.length - 1]]
} else {
return overlappingNodes[overlappingNodes.length - 1]
}
} else {
return undefined
}
}
/**
* retrieve all edges overlapping with given object, selector is around center
* @param {Object} object An object with parameters left, top, right, bottom
* @param {number[]} overlappingEdges An array with id's of the overlapping nodes
* @private
*/
_getEdgesOverlappingWith(object, overlappingEdges) {
let edges = this.body.edges
for (let i = 0; i < this.body.edgeIndices.length; i++) {
let edgeId = this.body.edgeIndices[i]
if (edges[edgeId].isOverlappingWith(object)) {
overlappingEdges.push(edgeId)
}
}
}
/**
* retrieve all nodes overlapping with given object
* @param {Object} object An object with parameters left, top, right, bottom
* @return {number[]} An array with id's of the overlapping nodes
* @private
*/
_getAllEdgesOverlappingWith(object) {
let overlappingEdges = []
this._getEdgesOverlappingWith(object, overlappingEdges)
return overlappingEdges
}
/**
* Get the edges nearest to the passed point (like a click)
*
* @param {{x: number, y: number}} pointer
* @param {boolean} [returnEdge=true]
* @return {Edge | undefined} node
*/
getEdgeAt(pointer, returnEdge = true) {
// Iterate over edges, pick closest within 10
var canvasPos = this.canvas.DOMtoCanvas(pointer)
var mindist = 10
var overlappingEdge = null
var edges = this.body.edges
for (var i = 0; i < this.body.edgeIndices.length; i++) {
var edgeId = this.body.edgeIndices[i]
var edge = edges[edgeId]
if (edge.connected) {
var xFrom = edge.from.x
var yFrom = edge.from.y
var xTo = edge.to.x
var yTo = edge.to.y
var dist = edge.edgeType.getDistanceToEdge(
xFrom,
yFrom,
xTo,
yTo,
canvasPos.x,
canvasPos.y
)
if (dist < mindist) {
overlappingEdge = edgeId
mindist = dist
}
}
}
if (overlappingEdge !== null) {
if (returnEdge === true) {
return this.body.edges[overlappingEdge]
} else {
return overlappingEdge
}
} else {
return undefined
}
}
/**
* Add object to the selection array.
*
* @param {Object} obj
* @private
*/
_addToSelection(obj) {
if (obj instanceof Node) {
this.selectionObj.nodes[obj.id] = obj
} else {
this.selectionObj.edges[obj.id] = obj
}
}
/**
* Add object to the selection array.
*
* @param {Object} obj
* @private
*/
_addToHover(obj) {
if (obj instanceof Node) {
this.hoverObj.nodes[obj.id] = obj
} else {
this.hoverObj.edges[obj.id] = obj
}
}
/**
* Remove a single option from selection.
*
* @param {Object} obj
* @private
*/
_removeFromSelection(obj) {
if (obj instanceof Node) {
delete this.selectionObj.nodes[obj.id]
this._unselectConnectedEdges(obj)
} else {
delete this.selectionObj.edges[obj.id]
}
}
/**
* Unselect all. The selectionObj is useful for this.
*/
unselectAll() {
for (let nodeId in this.selectionObj.nodes) {
if (this.selectionObj.nodes.hasOwnProperty(nodeId)) {
this.selectionObj.nodes[nodeId].unselect()
}
}
for (let edgeId in this.selectionObj.edges) {
if (this.selectionObj.edges.hasOwnProperty(edgeId)) {
this.selectionObj.edges[edgeId].unselect()
}
}
this.selectionObj = { nodes: {}, edges: {} }
}
/**
* return the number of selected nodes
*
* @returns {number}
* @private
*/
_getSelectedNodeCount() {
let count = 0
for (let nodeId in this.selectionObj.nodes) {
if (this.selectionObj.nodes.hasOwnProperty(nodeId)) {
count += 1
}
}
return count
}
/**
* return the selected node
*
* @returns {number}
* @private
*/
_getSelectedNode() {
for (let nodeId in this.selectionObj.nodes) {
if (this.selectionObj.nodes.hasOwnProperty(nodeId)) {
return this.selectionObj.nodes[nodeId]
}
}
return undefined
}
/**
* return the selected edge
*
* @returns {number}
* @private
*/
_getSelectedEdge() {
for (let edgeId in this.selectionObj.edges) {
if (this.selectionObj.edges.hasOwnProperty(edgeId)) {
return this.selectionObj.edges[edgeId]
}
}
return undefined
}
/**
* return the number of selected edges
*
* @returns {number}
* @private
*/
_getSelectedEdgeCount() {
let count = 0
for (let edgeId in this.selectionObj.edges) {
if (this.selectionObj.edges.hasOwnProperty(edgeId)) {
count += 1
}
}
return count
}
/**
* return the number of selected objects.
*
* @returns {number}
* @private
*/
_getSelectedObjectCount() {
let count = 0
for (let nodeId in this.selectionObj.nodes) {
if (this.selectionObj.nodes.hasOwnProperty(nodeId)) {
count += 1
}
}
for (let edgeId in this.selectionObj.edges) {
if (this.selectionObj.edges.hasOwnProperty(edgeId)) {
count += 1
}
}
return count
}
/**
* Check if anything is selected
*
* @returns {boolean}
* @private
*/
_selectionIsEmpty() {
for (let nodeId in this.selectionObj.nodes) {
if (this.selectionObj.nodes.hasOwnProperty(nodeId)) {
return false
}
}
for (let edgeId in this.selectionObj.edges) {
if (this.selectionObj.edges.hasOwnProperty(edgeId)) {
return false
}
}
return true
}
/**
* check if one of the selected nodes is a cluster.
*
* @returns {boolean}
* @private
*/
_clusterInSelection() {
for (let nodeId in this.selectionObj.nodes) {
if (this.selectionObj.nodes.hasOwnProperty(nodeId)) {
if (this.selectionObj.nodes[nodeId].clusterSize > 1) {
return true
}
}
}
return false
}
/**
* select the edges connected to the node that is being selected
*
* @param {Node} node
* @private
*/
_selectConnectedEdges(node) {
for (let i = 0; i < node.edges.length; i++) {
let edge = node.edges[i]
edge.select()
this._addToSelection(edge)
}
}
/**
* select the edges connected to the node that is being selected
*
* @param {Node} node
* @private
*/
_hoverConnectedEdges(node) {
for (let i = 0; i < node.edges.length; i++) {
let edge = node.edges[i]
edge.hover = true
this._addToHover(edge)
}
}
/**
* unselect the edges connected to the node that is being selected
*
* @param {Node} node
* @private
*/
_unselectConnectedEdges(node) {
for (let i = 0; i < node.edges.length; i++) {
let edge = node.edges[i]
edge.unselect()
this._removeFromSelection(edge)
}
}
/**
* Remove the highlight from a node or edge, in response to mouse movement
*
* @param {Event} event
* @param {{x: number, y: number}} pointer object with the x and y screen coordinates of the mouse
* @param {Node|vis.Edge} object
* @private
*/
emitBlurEvent(event, pointer, object) {
let properties = this._initBaseEvent(event, pointer)
if (object.hover === true) {
object.hover = false
if (object instanceof Node) {
properties.node = object.id
this.body.emitter.emit('blurNode', properties)
} else {
properties.edge = object.id
this.body.emitter.emit('blurEdge', properties)
}
}
}
/**
* Create the highlight for a node or edge, in response to mouse movement
*
* @param {Event} event
* @param {{x: number, y: number}} pointer object with the x and y screen coordinates of the mouse
* @param {Node|vis.Edge} object
* @returns {boolean} hoverChanged
* @private
*/
emitHoverEvent(event, pointer, object) {
let properties = this._initBaseEvent(event, pointer)
let hoverChanged = false
if (object.hover === false) {
object.hover = true
this._addToHover(object)
hoverChanged = true
if (object instanceof Node) {
properties.node = object.id
this.body.emitter.emit('hoverNode', properties)
} else {
properties.edge = object.id
this.body.emitter.emit('hoverEdge', properties)
}
}
return hoverChanged
}
/**
* Perform actions in response to a mouse movement.
*
* @param {Event} event
* @param {{x: number, y: number}} pointer | object with the x and y screen coordinates of the mouse
*/
hoverObject(event, pointer) {
let object = this.getNodeAt(pointer)
if (object === undefined) {
object = this.getEdgeAt(pointer)
}
let hoverChanged = false
// remove all node hover highlights
for (let nodeId in this.hoverObj.nodes) {
if (this.hoverObj.nodes.hasOwnProperty(nodeId)) {
if (
object === undefined ||
(object instanceof Node && object.id != nodeId) ||
object instanceof Edge
) {
this.emitBlurEvent(event, pointer, this.hoverObj.nodes[nodeId])
delete this.hoverObj.nodes[nodeId]
hoverChanged = true
}
}
}
// removing all edge hover highlights
for (let edgeId in this.hoverObj.edges) {
if (this.hoverObj.edges.hasOwnProperty(edgeId)) {
// if the hover has been changed here it means that the node has been hovered over or off
// we then do not use the emitBlurEvent method here.
if (hoverChanged === true) {
this.hoverObj.edges[edgeId].hover = false
delete this.hoverObj.edges[edgeId]
}
// if the blur remains the same and the object is undefined (mouse off) or another
// edge has been hovered, or another node has been hovered we blur the edge.
else if (
object === undefined ||
(object instanceof Edge && object.id != edgeId) ||
(object instanceof Node && !object.hover)
) {
this.emitBlurEvent(event, pointer, this.hoverObj.edges[edgeId])
delete this.hoverObj.edges[edgeId]
hoverChanged = true
}
}
}
if (object !== undefined) {
const hoveredEdgesCount = Object.keys(this.hoverObj.edges).length
const hoveredNodesCount = Object.keys(this.hoverObj.nodes).length
const newOnlyHoveredEdge =
object instanceof Edge &&
hoveredEdgesCount === 0 &&
hoveredNodesCount === 0
const newOnlyHoveredNode =
object instanceof Node &&
hoveredEdgesCount === 0 &&
hoveredNodesCount === 0
if (hoverChanged || newOnlyHoveredEdge || newOnlyHoveredNode) {
hoverChanged = this.emitHoverEvent(event, pointer, object)
}
if (object instanceof Node && this.options.hoverConnectedEdges === true) {
this._hoverConnectedEdges(object)
}
}
if (hoverChanged === true) {
this.body.emitter.emit('_requestRedraw')
}
}
/**
*
* retrieve the currently selected objects
* @return {{nodes: Array.<string>, edges: Array.<string>}} selection
*/
getSelection() {
let nodeIds = this.getSelectedNodes()
let edgeIds = this.getSelectedEdges()
return { nodes: nodeIds, edges: edgeIds }
}
/**
*
* retrieve the currently selected nodes
* @return {string[]} selection An array with the ids of the
* selected nodes.
*/
getSelectedNodes() {
let idArray = []
if (this.options.selectable === true) {
for (let nodeId in this.selectionObj.nodes) {
if (this.selectionObj.nodes.hasOwnProperty(nodeId)) {
idArray.push(this.selectionObj.nodes[nodeId].id)
}
}
}
return idArray
}
/**
*
* retrieve the currently selected edges
* @return {Array} selection An array with the ids of the
* selected nodes.
*/
getSelectedEdges() {
let idArray = []
if (this.options.selectable === true) {
for (let edgeId in this.selectionObj.edges) {
if (this.selectionObj.edges.hasOwnProperty(edgeId)) {
idArray.push(this.selectionObj.edges[edgeId].id)
}
}
}
return idArray
}
/**
* Updates the current selection
* @param {{nodes: Array.<string>, edges: Array.<string>}} selection
* @param {Object} options Options
*/
setSelection(selection, options = {}) {
let i, id
if (!selection || (!selection.nodes && !selection.edges))
throw 'Selection must be an object with nodes and/or edges properties'
// first unselect any selected node, if option is true or undefined
if (options.unselectAll || options.unselectAll === undefined) {
this.unselectAll()
}
if (selection.nodes) {
for (i = 0; i < selection.nodes.length; i++) {
id = selection.nodes[i]
let node = this.body.nodes[id]
if (!node) {
throw new RangeError('Node with id "' + id + '" not found')
}
// don't select edges with it
this.selectObject(node, options.highlightEdges)
}
}
if (selection.edges) {
for (i = 0; i < selection.edges.length; i++) {
id = selection.edges[i]
let edge = this.body.edges[id]
if (!edge) {
throw new RangeError('Edge with id "' + id + '" not found')
}
this.selectObject(edge)
}
}
this.body.emitter.emit('_requestRedraw')
}
/**
* select zero or more nodes with the option to highlight edges
* @param {number[] | string[]} selection An array with the ids of the
* selected nodes.
* @param {boolean} [highlightEdges]
*/
selectNodes(selection, highlightEdges = true) {
if (!selection || selection.length === undefined)
throw 'Selection must be an array with ids'
this.setSelection({ nodes: selection }, { highlightEdges: highlightEdges })
}
/**
* select zero or more edges
* @param {number[] | string[]} selection An array with the ids of the
* selected nodes.
*/
selectEdges(selection) {
if (!selection || selection.length === undefined)
throw 'Selection must be an array with ids'
this.setSelection({ edges: selection })
}
/**
* Validate the selection: remove ids of nodes which no longer exist
* @private
*/
updateSelection() {
for (let nodeId in this.selectionObj.nodes) {
if (this.selectionObj.nodes.hasOwnProperty(nodeId)) {
if (!this.body.nodes.hasOwnProperty(nodeId)) {
delete this.selectionObj.nodes[nodeId]
}
}
}
for (let edgeId in this.selectionObj.edges) {
if (this.selectionObj.edges.hasOwnProperty(edgeId)) {
if (!this.body.edges.hasOwnProperty(edgeId)) {
delete this.selectionObj.edges[edgeId]
}
}
}
}
/**
* Determine all the visual elements clicked which are on the given point.
*
* All elements are returned; this includes nodes, edges and their labels.
* The order returned is from highest to lowest, i.e. element 0 of the return
* value is the topmost item clicked on.
*
* The return value consists of an array of the following possible elements:
*
* - `{nodeId:number}` - node with given id clicked on
* - `{nodeId:number, labelId:0}` - label of node with given id clicked on
* - `{edgeId:number}` - edge with given id clicked on
* - `{edge:number, labelId:0}` - label of edge with given id clicked on
*
* ## NOTES
*
* - Currently, there is only one label associated with a node or an edge,
* but this is expected to change somewhere in the future.
* - Since there is no z-indexing yet, it is not really possible to set the nodes and
* edges in the correct order. For the time being, nodes come first.
*
* @param {point} pointer mouse position in screen coordinates
* @returns {Array.<nodeClickItem|nodeLabelClickItem|edgeClickItem|edgeLabelClickItem>}
* @private
*/
getClickedItems(pointer) {
let point = this.canvas.DOMtoCanvas(pointer)
var items = []
// Note reverse order; we want the topmost clicked items to be first in the array
// Also note that selected nodes are disregarded here; these normally display on top
let nodeIndices = this.body.nodeIndices
let nodes = this.body.nodes
for (let i = nodeIndices.length - 1; i >= 0; i--) {
let node = nodes[nodeIndices[i]]
let ret = node.getItemsOnPoint(point)
items.push.apply(items, ret) // Append the return value to the running list.
}
let edgeIndices = this.body.edgeIndices
let edges = this.body.edges
for (let i = edgeIndices.length - 1; i >= 0; i--) {
let edge = edges[edgeIndices[i]]
let ret = edge.getItemsOnPoint(point)
items.push.apply(items, ret) // Append the return value to the running list.
}
return items
}
}
export default SelectionHandler