visjs-network
Version:
A dynamic, browser-based network visualization library.
917 lines (828 loc) • 26 kB
JavaScript
var util = require('../../../util')
var Label = require('./shared/Label').default
var ComponentUtil = require('./shared/ComponentUtil').default
var CubicBezierEdge = require('./edges/CubicBezierEdge').default
var BezierEdgeDynamic = require('./edges/BezierEdgeDynamic').default
var BezierEdgeStatic = require('./edges/BezierEdgeStatic').default
var StraightEdge = require('./edges/StraightEdge').default
/**
* An edge connects two nodes and has a specific direction.
*/
class Edge {
/**
* @param {Object} options values specific to this edge, must contain at least 'from' and 'to'
* @param {Object} body shared state from Network instance
* @param {Object} globalOptions options from the EdgesHandler instance
* @param {Object} defaultOptions default options from the EdgeHandler instance. Value and reference are constant
*/
constructor(options, body, globalOptions, defaultOptions) {
if (body === undefined) {
throw new Error('No body provided')
}
// Since globalOptions is constant in values as well as reference,
// Following needs to be done only once.
this.options = util.bridgeObject(globalOptions)
this.globalOptions = globalOptions
this.defaultOptions = defaultOptions
this.body = body
// initialize variables
this.id = undefined
this.fromId = undefined
this.toId = undefined
this.selected = false
this.hover = false
this.labelDirty = true
this.baseWidth = this.options.width
this.baseFontSize = this.options.font.size
this.from = undefined // a node
this.to = undefined // a node
this.edgeType = undefined
this.connected = false
this.labelModule = new Label(
this.body,
this.options,
true /* It's an edge label */
)
this.setOptions(options)
}
/**
* Set or overwrite options for the edge
* @param {Object} options an object with options
* @returns {null|boolean} null if no options, boolean if date changed
*/
setOptions(options) {
if (!options) {
return
}
// record old value of this.options.hidden
let oldHidden = this.options.hidden
if (oldHidden === undefined || oldHidden === null) {
oldHidden = false
}
Edge.parseOptions(this.options, options, true, this.globalOptions)
if (options.id !== undefined) {
this.id = options.id
}
if (options.from !== undefined) {
this.fromId = options.from
}
if (options.to !== undefined) {
this.toId = options.to
}
if (options.title !== undefined) {
this.title = options.title
}
if (options.value !== undefined) {
options.value = parseFloat(options.value)
}
let pile = [options, this.options, this.defaultOptions]
this.chooser = ComponentUtil.choosify('edge', pile)
// update label Module
this.updateLabelModule(options)
let dataChanged = this.updateEdgeType()
// if anything has been updates, reset the selection width and the hover width
this._setInteractionWidths()
// A node is connected when it has a from and to node that both exist in the network.body.nodes.
this.connect()
let newHidden = this.options.hidden
if (newHidden === undefined || newHidden === null) {
newHidden = false
}
if (newHidden != oldHidden || options.physics !== undefined) {
dataChanged = true
}
return dataChanged
}
/**
*
* @param {Object} parentOptions
* @param {Object} newOptions
* @param {boolean} [allowDeletion=false]
* @param {Object} [globalOptions={}]
* @param {boolean} [copyFromGlobals=false]
*/
static parseOptions(
parentOptions,
newOptions,
allowDeletion = false,
globalOptions = {},
copyFromGlobals = false
) {
var fields = [
'arrowStrikethrough',
'id',
'from',
'hidden',
'hoverWidth',
'labelHighlightBold',
'length',
'line',
'opacity',
'physics',
'scaling',
'selectionWidth',
'selfReferenceSize',
'to',
'title',
'value',
'width',
'font',
'chosen',
'widthConstraint'
]
// only deep extend the items in the field array. These do not have shorthand.
util.selectiveDeepExtend(fields, parentOptions, newOptions, allowDeletion)
// Only copy label if it's a legal value.
if (ComponentUtil.isValidLabel(newOptions.label)) {
parentOptions.label = newOptions.label
} else if (!ComponentUtil.isValidLabel(parentOptions.label)) {
parentOptions.label = undefined
}
util.mergeOptions(parentOptions, newOptions, 'smooth', globalOptions)
util.mergeOptions(parentOptions, newOptions, 'shadow', globalOptions)
util.mergeOptions(parentOptions, newOptions, 'background', globalOptions)
if (newOptions.dashes !== undefined && newOptions.dashes !== null) {
parentOptions.dashes = newOptions.dashes
} else if (allowDeletion === true && newOptions.dashes === null) {
parentOptions.dashes = Object.create(globalOptions.dashes) // this sets the pointer of the option back to the global option.
}
// set the scaling newOptions
if (newOptions.scaling !== undefined && newOptions.scaling !== null) {
if (newOptions.scaling.min !== undefined) {
parentOptions.scaling.min = newOptions.scaling.min
}
if (newOptions.scaling.max !== undefined) {
parentOptions.scaling.max = newOptions.scaling.max
}
util.mergeOptions(
parentOptions.scaling,
newOptions.scaling,
'label',
globalOptions.scaling
)
} else if (allowDeletion === true && newOptions.scaling === null) {
parentOptions.scaling = Object.create(globalOptions.scaling) // this sets the pointer of the option back to the global option.
}
// handle multiple input cases for arrows
if (newOptions.arrows !== undefined && newOptions.arrows !== null) {
if (typeof newOptions.arrows === 'string') {
let arrows = newOptions.arrows.toLowerCase()
parentOptions.arrows.to.enabled = arrows.indexOf('to') != -1
parentOptions.arrows.middle.enabled = arrows.indexOf('middle') != -1
parentOptions.arrows.from.enabled = arrows.indexOf('from') != -1
} else if (typeof newOptions.arrows === 'object') {
util.mergeOptions(
parentOptions.arrows,
newOptions.arrows,
'to',
globalOptions.arrows
)
util.mergeOptions(
parentOptions.arrows,
newOptions.arrows,
'middle',
globalOptions.arrows
)
util.mergeOptions(
parentOptions.arrows,
newOptions.arrows,
'from',
globalOptions.arrows
)
} else {
throw new Error(
'The arrow newOptions can only be an object or a string. Refer to the documentation. You used:' +
JSON.stringify(newOptions.arrows)
)
}
} else if (allowDeletion === true && newOptions.arrows === null) {
parentOptions.arrows = Object.create(globalOptions.arrows) // this sets the pointer of the option back to the global option.
}
// handle multiple input cases for color
if (newOptions.color !== undefined && newOptions.color !== null) {
let fromColor = newOptions.color
let toColor = parentOptions.color
// If passed, fill in values from default options - required in the case of no prototype bridging
if (copyFromGlobals) {
util.deepExtend(toColor, globalOptions.color, false, allowDeletion)
} else {
// Clear local properties - need to do it like this in order to retain prototype bridges
for (var i in toColor) {
if (toColor.hasOwnProperty(i)) {
delete toColor[i]
}
}
}
if (util.isString(toColor)) {
toColor.color = toColor
toColor.highlight = toColor
toColor.hover = toColor
toColor.inherit = false
if (fromColor.opacity === undefined) {
toColor.opacity = 1.0 // set default
}
} else {
let colorsDefined = false
if (fromColor.color !== undefined) {
toColor.color = fromColor.color
colorsDefined = true
}
if (fromColor.highlight !== undefined) {
toColor.highlight = fromColor.highlight
colorsDefined = true
}
if (fromColor.hover !== undefined) {
toColor.hover = fromColor.hover
colorsDefined = true
}
if (fromColor.inherit !== undefined) {
toColor.inherit = fromColor.inherit
}
if (fromColor.opacity !== undefined) {
toColor.opacity = Math.min(1, Math.max(0, fromColor.opacity))
}
if (colorsDefined === true) {
toColor.inherit = false
} else {
if (toColor.inherit === undefined) {
toColor.inherit = 'from' // Set default
}
}
}
} else if (allowDeletion === true && newOptions.color === null) {
parentOptions.color = util.bridgeObject(globalOptions.color) // set the object back to the global options
}
if (allowDeletion === true && newOptions.font === null) {
parentOptions.font = util.bridgeObject(globalOptions.font) // set the object back to the global options
}
}
/**
*
* @returns {ArrowOptions}
*/
getFormattingValues() {
let toArrow =
this.options.arrows.to === true || this.options.arrows.to.enabled === true
let fromArrow =
this.options.arrows.from === true ||
this.options.arrows.from.enabled === true
let middleArrow =
this.options.arrows.middle === true ||
this.options.arrows.middle.enabled === true
let inheritsColor = this.options.color.inherit
let values = {
toArrow: toArrow,
toArrowScale: this.options.arrows.to.scaleFactor,
toArrowType: this.options.arrows.to.type,
middleArrow: middleArrow,
middleArrowScale: this.options.arrows.middle.scaleFactor,
middleArrowType: this.options.arrows.middle.type,
fromArrow: fromArrow,
fromArrowScale: this.options.arrows.from.scaleFactor,
fromArrowType: this.options.arrows.from.type,
arrowStrikethrough: this.options.arrowStrikethrough,
color: inheritsColor ? undefined : this.options.color.color,
inheritsColor: inheritsColor,
opacity: this.options.color.opacity,
hidden: this.options.hidden,
length: this.options.length,
shadow: this.options.shadow.enabled,
shadowColor: this.options.shadow.color,
shadowSize: this.options.shadow.size,
shadowX: this.options.shadow.x,
shadowY: this.options.shadow.y,
dashes: this.options.dashes,
width: this.options.width,
background: this.options.background.enabled,
backgroundColor: this.options.background.color,
backgroundSize: this.options.background.size,
backgroundDashes: this.options.background.dashes
}
if (this.selected || this.hover) {
if (this.chooser === true) {
if (this.selected) {
let selectedWidth = this.options.selectionWidth
if (typeof selectedWidth === 'function') {
values.width = selectedWidth(values.width)
} else if (typeof selectedWidth === 'number') {
values.width += selectedWidth
}
values.width = Math.max(values.width, 0.3 / this.body.view.scale)
values.color = this.options.color.highlight
values.shadow = this.options.shadow.enabled
} else if (this.hover) {
let hoverWidth = this.options.hoverWidth
if (typeof hoverWidth === 'function') {
values.width = hoverWidth(values.width)
} else if (typeof hoverWidth === 'number') {
values.width += hoverWidth
}
values.width = Math.max(values.width, 0.3 / this.body.view.scale)
values.color = this.options.color.hover
values.shadow = this.options.shadow.enabled
}
} else if (typeof this.chooser === 'function') {
this.chooser(values, this.options.id, this.selected, this.hover)
if (values.color !== undefined) {
values.inheritsColor = false
}
if (values.shadow === false) {
if (
values.shadowColor !== this.options.shadow.color ||
values.shadowSize !== this.options.shadow.size ||
values.shadowX !== this.options.shadow.x ||
values.shadowY !== this.options.shadow.y
) {
values.shadow = true
}
}
}
} else {
values.shadow = this.options.shadow.enabled
values.width = Math.max(values.width, 0.3 / this.body.view.scale)
}
return values
}
/**
* update the options in the label module
*
* @param {Object} options
*/
updateLabelModule(options) {
let pile = [
options,
this.options,
this.globalOptions, // Currently set global edge options
this.defaultOptions
]
this.labelModule.update(this.options, pile)
if (this.labelModule.baseSize !== undefined) {
this.baseFontSize = this.labelModule.baseSize
}
}
/**
* update the edge type, set the options
* @returns {boolean}
*/
updateEdgeType() {
let smooth = this.options.smooth
let dataChanged = false
let changeInType = true
if (this.edgeType !== undefined) {
if (
(this.edgeType instanceof BezierEdgeDynamic &&
smooth.enabled === true &&
smooth.type === 'dynamic') ||
(this.edgeType instanceof CubicBezierEdge &&
smooth.enabled === true &&
smooth.type === 'cubicBezier') ||
(this.edgeType instanceof BezierEdgeStatic &&
smooth.enabled === true &&
smooth.type !== 'dynamic' &&
smooth.type !== 'cubicBezier') ||
(this.edgeType instanceof StraightEdge && smooth.type.enabled === false)
) {
changeInType = false
}
if (changeInType === true) {
dataChanged = this.cleanup()
}
}
if (changeInType === true) {
if (smooth.enabled === true) {
if (smooth.type === 'dynamic') {
dataChanged = true
this.edgeType = new BezierEdgeDynamic(
this.options,
this.body,
this.labelModule
)
} else if (smooth.type === 'cubicBezier') {
this.edgeType = new CubicBezierEdge(
this.options,
this.body,
this.labelModule
)
} else {
this.edgeType = new BezierEdgeStatic(
this.options,
this.body,
this.labelModule
)
}
} else {
this.edgeType = new StraightEdge(
this.options,
this.body,
this.labelModule
)
}
} else {
// if nothing changes, we just set the options.
this.edgeType.setOptions(this.options)
}
return dataChanged
}
/**
* Connect an edge to its nodes
*/
connect() {
this.disconnect()
this.from = this.body.nodes[this.fromId] || undefined
this.to = this.body.nodes[this.toId] || undefined
this.connected = this.from !== undefined && this.to !== undefined
if (this.connected === true) {
this.from.attachEdge(this)
this.to.attachEdge(this)
} else {
if (this.from) {
this.from.detachEdge(this)
}
if (this.to) {
this.to.detachEdge(this)
}
}
this.edgeType.connect()
}
/**
* Disconnect an edge from its nodes
*/
disconnect() {
if (this.from) {
this.from.detachEdge(this)
this.from = undefined
}
if (this.to) {
this.to.detachEdge(this)
this.to = undefined
}
this.connected = false
}
/**
* get the title of this edge.
* @return {string} title The title of the edge, or undefined when no title
* has been set.
*/
getTitle() {
return this.title
}
/**
* check if this node is selecte
* @return {boolean} selected True if node is selected, else false
*/
isSelected() {
return this.selected
}
/**
* Retrieve the value of the edge. Can be undefined
* @return {number} value
*/
getValue() {
return this.options.value
}
/**
* Adjust the value range of the edge. The edge will adjust it's width
* based on its value.
* @param {number} min
* @param {number} max
* @param {number} total
*/
setValueRange(min, max, total) {
if (this.options.value !== undefined) {
var scale = this.options.scaling.customScalingFunction(
min,
max,
total,
this.options.value
)
var widthDiff = this.options.scaling.max - this.options.scaling.min
if (this.options.scaling.label.enabled === true) {
var fontDiff =
this.options.scaling.label.max - this.options.scaling.label.min
this.options.font.size =
this.options.scaling.label.min + scale * fontDiff
}
this.options.width = this.options.scaling.min + scale * widthDiff
} else {
this.options.width = this.baseWidth
this.options.font.size = this.baseFontSize
}
this._setInteractionWidths()
this.updateLabelModule()
}
/**
*
* @private
*/
_setInteractionWidths() {
if (typeof this.options.hoverWidth === 'function') {
this.edgeType.hoverWidth = this.options.hoverWidth(this.options.width)
} else {
this.edgeType.hoverWidth = this.options.hoverWidth + this.options.width
}
if (typeof this.options.selectionWidth === 'function') {
this.edgeType.selectionWidth = this.options.selectionWidth(
this.options.width
)
} else {
this.edgeType.selectionWidth =
this.options.selectionWidth + this.options.width
}
}
/**
* Redraw a edge
* Draw this edge in the given canvas
* The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d");
* @param {CanvasRenderingContext2D} ctx
*/
draw(ctx) {
let values = this.getFormattingValues()
if (values.hidden) {
return
}
// get the via node from the edge type
let viaNode = this.edgeType.getViaNode()
let arrowData = {}
// restore edge targets to defaults
this.edgeType.fromPoint = this.edgeType.from
this.edgeType.toPoint = this.edgeType.to
// from and to arrows give a different end point for edges. we set them here
if (values.fromArrow) {
arrowData.from = this.edgeType.getArrowData(
ctx,
'from',
viaNode,
this.selected,
this.hover,
values
)
if (values.arrowStrikethrough === false)
this.edgeType.fromPoint = arrowData.from.core
}
if (values.toArrow) {
arrowData.to = this.edgeType.getArrowData(
ctx,
'to',
viaNode,
this.selected,
this.hover,
values
)
if (values.arrowStrikethrough === false)
this.edgeType.toPoint = arrowData.to.core
}
// the middle arrow depends on the line, which can depend on the to and from arrows so we do this one lastly.
if (values.middleArrow) {
arrowData.middle = this.edgeType.getArrowData(
ctx,
'middle',
viaNode,
this.selected,
this.hover,
values
)
}
// draw everything
this.edgeType.drawLine(ctx, values, this.selected, this.hover, viaNode)
this.drawArrows(ctx, arrowData, values)
this.drawLabel(ctx, viaNode)
}
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {Object} arrowData
* @param {ArrowOptions} values
*/
drawArrows(ctx, arrowData, values) {
if (values.fromArrow) {
this.edgeType.drawArrowHead(
ctx,
values,
this.selected,
this.hover,
arrowData.from
)
}
if (values.middleArrow) {
this.edgeType.drawArrowHead(
ctx,
values,
this.selected,
this.hover,
arrowData.middle
)
}
if (values.toArrow) {
this.edgeType.drawArrowHead(
ctx,
values,
this.selected,
this.hover,
arrowData.to
)
}
}
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {Node} viaNode
*/
drawLabel(ctx, viaNode) {
if (this.options.label !== undefined) {
// set style
var node1 = this.from
var node2 = this.to
if (this.labelModule.differentState(this.selected, this.hover)) {
this.labelModule.getTextSize(ctx, this.selected, this.hover)
}
if (node1.id != node2.id) {
this.labelModule.pointToSelf = false
var point = this.edgeType.getPoint(0.5, viaNode)
ctx.save()
let rotationPoint = this._getRotation(ctx)
if (rotationPoint.angle != 0) {
ctx.translate(rotationPoint.x, rotationPoint.y)
ctx.rotate(rotationPoint.angle)
}
// draw the label
this.labelModule.draw(ctx, point.x, point.y, this.selected, this.hover)
/*
// Useful debug code: draw a border around the label
// This should **not** be enabled in production!
var size = this.labelModule.getSize();; // ;; intentional so lint catches it
ctx.strokeStyle = "#ff0000";
ctx.strokeRect(size.left, size.top, size.width, size.height);
// End debug code
*/
ctx.restore()
} else {
// Ignore the orientations.
this.labelModule.pointToSelf = true
var x, y
var radius = this.options.selfReferenceSize
if (node1.shape.width > node1.shape.height) {
x = node1.x + node1.shape.width * 0.5
y = node1.y - radius
} else {
x = node1.x + radius
y = node1.y - node1.shape.height * 0.5
}
point = this._pointOnCircle(x, y, radius, 0.125)
this.labelModule.draw(ctx, point.x, point.y, this.selected, this.hover)
}
}
}
/**
* Determine all visual elements of this edge instance, in which the given
* point falls within the bounding shape.
*
* @param {point} point
* @returns {Array.<edgeClickItem|edgeLabelClickItem>} list with the items which are on the point
*/
getItemsOnPoint(point) {
var ret = []
if (this.labelModule.visible()) {
let rotationPoint = this._getRotation()
if (
ComponentUtil.pointInRect(
this.labelModule.getSize(),
point,
rotationPoint
)
) {
ret.push({ edgeId: this.id, labelId: 0 })
}
}
let obj = {
left: point.x,
top: point.y
}
if (this.isOverlappingWith(obj)) {
ret.push({ edgeId: this.id })
}
return ret
}
/**
* Check if this object is overlapping with the provided object
* @param {Object} obj an object with parameters left, top
* @return {boolean} True if location is located on the edge
*/
isOverlappingWith(obj) {
if (this.connected) {
var distMax = 10
var xFrom = this.from.x
var yFrom = this.from.y
var xTo = this.to.x
var yTo = this.to.y
var xObj = obj.left
var yObj = obj.top
var dist = this.edgeType.getDistanceToEdge(
xFrom,
yFrom,
xTo,
yTo,
xObj,
yObj
)
return dist < distMax
} else {
return false
}
}
/**
* Determine the rotation point, if any.
*
* @param {CanvasRenderingContext2D} [ctx] if passed, do a recalculation of the label size
* @returns {rotationPoint} the point to rotate around and the angle in radians to rotate
* @private
*/
_getRotation(ctx) {
let viaNode = this.edgeType.getViaNode()
let point = this.edgeType.getPoint(0.5, viaNode)
if (ctx !== undefined) {
this.labelModule.calculateLabelSize(
ctx,
this.selected,
this.hover,
point.x,
point.y
)
}
let ret = {
x: point.x,
y: this.labelModule.size.yLine,
angle: 0
}
if (!this.labelModule.visible()) {
return ret // Don't even bother doing the atan2, there's nothing to draw
}
if (this.options.font.align === 'horizontal') {
return ret // No need to calculate angle
}
var dy = this.from.y - this.to.y
var dx = this.from.x - this.to.x
var angle = Math.atan2(dy, dx) // radians
// rotate so that label is readable
if ((angle < -1 && dx < 0) || (angle > 0 && dx < 0)) {
angle += Math.PI
}
ret.angle = angle
return ret
}
/**
* Get a point on a circle
* @param {number} x
* @param {number} y
* @param {number} radius
* @param {number} percentage Value between 0 (line start) and 1 (line end)
* @return {Object} point
* @private
*/
_pointOnCircle(x, y, radius, percentage) {
var angle = percentage * 2 * Math.PI
return {
x: x + radius * Math.cos(angle),
y: y - radius * Math.sin(angle)
}
}
/**
* Sets selected state to true
*/
select() {
this.selected = true
}
/**
* Sets selected state to false
*/
unselect() {
this.selected = false
}
/**
* cleans all required things on delete
* @returns {*}
*/
cleanup() {
if (this.edgeType) {
return this.edgeType.cleanup()
}
}
/**
* Remove edge from the list and perform necessary cleanup.
*/
remove() {
this.cleanup()
this.disconnect()
delete this.body.edges[this.id]
}
/**
* Check if both connecting nodes exist
* @returns {boolean}
*/
endPointsValid() {
return (
this.body.nodes[this.fromId] !== undefined &&
this.body.nodes[this.toId] !== undefined
)
}
}
export default Edge