visjs-network
Version:
A dynamic, browser-based network visualization library.
802 lines (719 loc) • 24.7 kB
JavaScript
let util = require('../../../../util')
let ComponentUtil = require('./ComponentUtil').default
let LabelSplitter = require('./LabelSplitter').default
/**
* List of special styles for multi-fonts
* @private
*/
const multiFontStyle = ['bold', 'ital', 'boldital', 'mono']
/**
* A Label to be used for Nodes or Edges.
*/
class Label {
/**
* @param {Object} body
* @param {Object} options
* @param {boolean} [edgelabel=false]
*/
constructor(body, options, edgelabel = false) {
this.body = body
this.pointToSelf = false
this.baseSize = undefined
this.fontOptions = {} // instance variable containing the *instance-local* font options
this.setOptions(options)
this.size = { top: 0, left: 0, width: 0, height: 0, yLine: 0 }
this.isEdgeLabel = edgelabel
}
/**
* @param {Object} options the options of the parent Node-instance
*/
setOptions(options) {
this.elementOptions = options // Reference to the options of the parent Node-instance
this.initFontOptions(options.font)
if (ComponentUtil.isValidLabel(options.label)) {
this.labelDirty = true
} else {
// Bad label! Change the option value to prevent bad stuff happening
options.label = undefined
}
if (options.font !== undefined && options.font !== null) {
// font options can be deleted at various levels
if (typeof options.font === 'string') {
this.baseSize = this.fontOptions.size
} else if (typeof options.font === 'object') {
let size = options.font.size
if (size !== undefined) {
this.baseSize = size
}
}
}
}
/**
* Init the font Options structure.
*
* Member fontOptions serves as an accumulator for the current font options.
* As such, it needs to be completely separated from the node options.
*
* @param {Object} newFontOptions the new font options to process
* @private
*/
initFontOptions(newFontOptions) {
// Prepare the multi-font option objects.
// These will be filled in propagateFonts(), if required
util.forEach(multiFontStyle, style => {
this.fontOptions[style] = {}
})
// Handle shorthand option, if present
if (Label.parseFontString(this.fontOptions, newFontOptions)) {
this.fontOptions.vadjust = 0
return
}
// Copy over the non-multifont options, if specified
util.forEach(newFontOptions, (prop, n) => {
if (prop !== undefined && prop !== null && typeof prop !== 'object') {
this.fontOptions[n] = prop
}
})
}
/**
* If in-variable is a string, parse it as a font specifier.
*
* Note that following is not done here and have to be done after the call:
* - No number conversion (size)
* - Not all font options are set (vadjust, mod)
*
* @param {Object} outOptions out-parameter, object in which to store the parse results (if any)
* @param {Object} inOptions font options to parse
* @return {boolean} true if font parsed as string, false otherwise
* @static
*/
static parseFontString(outOptions, inOptions) {
if (!inOptions || typeof inOptions !== 'string') return false
let newOptionsArray = inOptions.split(' ')
outOptions.size = newOptionsArray[0].replace('px', '')
outOptions.face = newOptionsArray[1]
outOptions.color = newOptionsArray[2]
return true
}
/**
* Set the width and height constraints based on 'nearest' value
*
* @param {Array} pile array of option objects to consider
* @returns {object} the actual constraint values to use
* @private
*/
constrain(pile) {
// NOTE: constrainWidth and constrainHeight never set!
// NOTE: for edge labels, only 'maxWdt' set
// Node labels can set all the fields
let fontOptions = {
constrainWidth: false,
maxWdt: -1,
minWdt: -1,
constrainHeight: false,
minHgt: -1,
valign: 'middle'
}
let widthConstraint = util.topMost(pile, 'widthConstraint')
if (typeof widthConstraint === 'number') {
fontOptions.maxWdt = Number(widthConstraint)
fontOptions.minWdt = Number(widthConstraint)
} else if (typeof widthConstraint === 'object') {
let widthConstraintMaximum = util.topMost(pile, [
'widthConstraint',
'maximum'
])
if (typeof widthConstraintMaximum === 'number') {
fontOptions.maxWdt = Number(widthConstraintMaximum)
}
let widthConstraintMinimum = util.topMost(pile, [
'widthConstraint',
'minimum'
])
if (typeof widthConstraintMinimum === 'number') {
fontOptions.minWdt = Number(widthConstraintMinimum)
}
}
let heightConstraint = util.topMost(pile, 'heightConstraint')
if (typeof heightConstraint === 'number') {
fontOptions.minHgt = Number(heightConstraint)
} else if (typeof heightConstraint === 'object') {
let heightConstraintMinimum = util.topMost(pile, [
'heightConstraint',
'minimum'
])
if (typeof heightConstraintMinimum === 'number') {
fontOptions.minHgt = Number(heightConstraintMinimum)
}
let heightConstraintValign = util.topMost(pile, [
'heightConstraint',
'valign'
])
if (typeof heightConstraintValign === 'string') {
if (
heightConstraintValign === 'top' ||
heightConstraintValign === 'bottom'
) {
fontOptions.valign = heightConstraintValign
}
}
}
return fontOptions
}
/**
* Set options and update internal state
*
* @param {Object} options options to set
* @param {Array} pile array of option objects to consider for option 'chosen'
*/
update(options, pile) {
this.setOptions(options, true)
this.propagateFonts(pile)
util.deepExtend(this.fontOptions, this.constrain(pile))
this.fontOptions.chooser = ComponentUtil.choosify('label', pile)
}
/**
* When margins are set in an element, adjust sizes is called to remove them
* from the width/height constraints. This must be done prior to label sizing.
*
* @param {{top: number, right: number, bottom: number, left: number}} margins
*/
adjustSizes(margins) {
let widthBias = margins ? margins.right + margins.left : 0
if (this.fontOptions.constrainWidth) {
this.fontOptions.maxWdt -= widthBias
this.fontOptions.minWdt -= widthBias
}
let heightBias = margins ? margins.top + margins.bottom : 0
if (this.fontOptions.constrainHeight) {
this.fontOptions.minHgt -= heightBias
}
}
/////////////////////////////////////////////////////////
// Methods for handling options piles
// Eventually, these will be moved to a separate class
/////////////////////////////////////////////////////////
/**
* Add the font members of the passed list of option objects to the pile.
*
* @param {Pile} dstPile pile of option objects add to
* @param {Pile} srcPile pile of option objects to take font options from
* @private
*/
addFontOptionsToPile(dstPile, srcPile) {
for (let i = 0; i < srcPile.length; ++i) {
this.addFontToPile(dstPile, srcPile[i])
}
}
/**
* Add given font option object to the list of objects (the 'pile') to consider for determining
* multi-font option values.
*
* @param {Pile} pile pile of option objects to use
* @param {object} options instance to add to pile
* @private
*/
addFontToPile(pile, options) {
if (options === undefined) return
if (options.font === undefined || options.font === null) return
let item = options.font
pile.push(item)
}
/**
* Collect all own-property values from the font pile that aren't multi-font option objectss.
*
* @param {Pile} pile pile of option objects to use
* @returns {object} object with all current own basic font properties
* @private
*/
getBasicOptions(pile) {
let ret = {}
// Scans the whole pile to get all options present
for (let n = 0; n < pile.length; ++n) {
let fontOptions = pile[n]
// Convert shorthand if necessary
let tmpShorthand = {}
if (Label.parseFontString(tmpShorthand, fontOptions)) {
fontOptions = tmpShorthand
}
util.forEach(fontOptions, (opt, name) => {
if (opt === undefined) return // multi-font option need not be present
if (ret.hasOwnProperty(name)) return // Keep first value we encounter
if (multiFontStyle.indexOf(name) !== -1) {
// Skip multi-font properties but we do need the structure
ret[name] = {}
} else {
ret[name] = opt
}
})
}
return ret
}
/**
* Return the value for given option for the given multi-font.
*
* All available option objects are trawled in the set order to construct the option values.
*
* ---------------------------------------------------------------------
* ## Traversal of pile for multi-fonts
*
* The determination of multi-font option values is a special case, because any values not
* present in the multi-font options should by definition be taken from the main font options,
* i.e. from the current 'parent' object of the multi-font option.
*
* ### Search order for multi-fonts
*
* 'bold' used as example:
*
* - search in option group 'bold' in local properties
* - search in main font option group in local properties
*
* ---------------------------------------------------------------------
*
* @param {Pile} pile pile of option objects to use
* @param {MultiFontStyle} multiName sub path for the multi-font
* @param {string} option the option to search for, for the given multi-font
* @returns {string|number} the value for the given option
* @private
*/
getFontOption(pile, multiName, option) {
let multiFont
// Search multi font in local properties
for (let n = 0; n < pile.length; ++n) {
let fontOptions = pile[n]
if (fontOptions.hasOwnProperty(multiName)) {
multiFont = fontOptions[multiName]
if (multiFont === undefined || multiFont === null) continue
// Convert shorthand if necessary
// TODO: inefficient to do this conversion every time; find a better way.
let tmpShorthand = {}
if (Label.parseFontString(tmpShorthand, multiFont)) {
multiFont = tmpShorthand
}
if (multiFont.hasOwnProperty(option)) {
return multiFont[option]
}
}
}
// Option is not mentioned in the multi font options; take it from the parent font options.
// These have already been converted with getBasicOptions(), so use the converted values.
if (this.fontOptions.hasOwnProperty(option)) {
return this.fontOptions[option]
}
// A value **must** be found; you should never get here.
throw new Error(
"Did not find value for multi-font for property: '" + option + "'"
)
}
/**
* Return all options values for the given multi-font.
*
* All available option objects are trawled in the set order to construct the option values.
*
* @param {Pile} pile pile of option objects to use
* @param {MultiFontStyle} multiName sub path for the mod-font
* @returns {MultiFontOptions}
* @private
*/
getFontOptions(pile, multiName) {
let result = {}
let optionNames = ['color', 'size', 'face', 'mod', 'vadjust'] // List of allowed options per multi-font
for (let i = 0; i < optionNames.length; ++i) {
let mod = optionNames[i]
result[mod] = this.getFontOption(pile, multiName, mod)
}
return result
}
/////////////////////////////////////////////////////////
// End methods for handling options piles
/////////////////////////////////////////////////////////
/**
* Collapse the font options for the multi-font to single objects, from
* the chain of option objects passed (the 'pile').
*
* @param {Pile} pile sequence of option objects to consider.
* First item in list assumed to be the newly set options.
*/
propagateFonts(pile) {
let fontPile = [] // sequence of font objects to consider, order important
// Note that this.elementOptions is not used here.
this.addFontOptionsToPile(fontPile, pile)
this.fontOptions = this.getBasicOptions(fontPile)
// We set multifont values even if multi === false, for consistency (things break otherwise)
for (let i = 0; i < multiFontStyle.length; ++i) {
let mod = multiFontStyle[i]
let modOptions = this.fontOptions[mod]
let tmpMultiFontOptions = this.getFontOptions(fontPile, mod)
// Copy over found values
util.forEach(tmpMultiFontOptions, (option, n) => {
modOptions[n] = option
})
modOptions.size = Number(modOptions.size)
modOptions.vadjust = Number(modOptions.vadjust)
}
}
/**
* Main function. This is called from anything that wants to draw a label.
* @param {CanvasRenderingContext2D} ctx
* @param {number} x
* @param {number} y
* @param {boolean} selected
* @param {boolean} hover
* @param {string} [baseline='middle']
*/
draw(ctx, x, y, selected, hover, baseline = 'middle') {
// if no label, return
if (this.elementOptions.label === undefined) return
// check if we have to render the label
let viewFontSize = this.fontOptions.size * this.body.view.scale
if (
this.elementOptions.label &&
viewFontSize < this.elementOptions.scaling.label.drawThreshold - 1
)
return
// This ensures that there will not be HUGE letters on screen
// by setting an upper limit on the visible text size (regardless of zoomLevel)
if (viewFontSize >= this.elementOptions.scaling.label.maxVisible) {
viewFontSize =
Number(this.elementOptions.scaling.label.maxVisible) /
this.body.view.scale
}
// update the size cache if required
this.calculateLabelSize(ctx, selected, hover, x, y, baseline)
this._drawBackground(ctx)
this._drawText(ctx, x, this.size.yLine, baseline, viewFontSize)
}
/**
* Draws the label background
* @param {CanvasRenderingContext2D} ctx
* @private
*/
_drawBackground(ctx) {
if (
this.fontOptions.background !== undefined &&
this.fontOptions.background !== 'none'
) {
ctx.fillStyle = this.fontOptions.background
let size = this.getSize()
ctx.fillRect(size.left, size.top, size.width, size.height)
}
}
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} x
* @param {number} y
* @param {string} [baseline='middle']
* @param {number} viewFontSize
* @private
*/
_drawText(ctx, x, y, baseline = 'middle', viewFontSize) {
;[x, y] = this._setAlignment(ctx, x, y, baseline)
ctx.textAlign = 'left'
x = x - this.size.width / 2 // Shift label 1/2-distance to the left
if (this.fontOptions.valign && this.size.height > this.size.labelHeight) {
if (this.fontOptions.valign === 'top') {
y -= (this.size.height - this.size.labelHeight) / 2
}
if (this.fontOptions.valign === 'bottom') {
y += (this.size.height - this.size.labelHeight) / 2
}
}
// draw the text
for (let i = 0; i < this.lineCount; i++) {
let line = this.lines[i]
if (line && line.blocks) {
let width = 0
if (this.isEdgeLabel || this.fontOptions.align === 'center') {
width += (this.size.width - line.width) / 2
} else if (this.fontOptions.align === 'right') {
width += this.size.width - line.width
}
for (let j = 0; j < line.blocks.length; j++) {
let block = line.blocks[j]
ctx.font = block.font
let [fontColor, strokeColor] = this._getColor(
block.color,
viewFontSize,
block.strokeColor
)
if (block.strokeWidth > 0) {
ctx.lineWidth = block.strokeWidth
ctx.strokeStyle = strokeColor
ctx.lineJoin = 'round'
}
ctx.fillStyle = fontColor
if (block.strokeWidth > 0) {
ctx.strokeText(block.text, x + width, y + block.vadjust)
}
ctx.fillText(block.text, x + width, y + block.vadjust)
width += block.width
}
y += line.height
}
}
}
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} x
* @param {number} y
* @param {string} baseline
* @returns {Array.<number>}
* @private
*/
_setAlignment(ctx, x, y, baseline) {
// check for label alignment (for edges)
// TODO: make alignment for nodes
if (
this.isEdgeLabel &&
this.fontOptions.align !== 'horizontal' &&
this.pointToSelf === false
) {
x = 0
y = 0
let lineMargin = 2
if (this.fontOptions.align === 'top') {
ctx.textBaseline = 'alphabetic'
y -= 2 * lineMargin // distance from edge, required because we use alphabetic. Alphabetic has less difference between browsers
} else if (this.fontOptions.align === 'bottom') {
ctx.textBaseline = 'hanging'
y += 2 * lineMargin // distance from edge, required because we use hanging. Hanging has less difference between browsers
} else {
ctx.textBaseline = 'middle'
}
} else {
ctx.textBaseline = baseline
}
return [x, y]
}
/**
* fade in when relative scale is between threshold and threshold - 1.
* If the relative scale would be smaller than threshold -1 the draw function would have returned before coming here.
*
* @param {string} color The font color to use
* @param {number} viewFontSize
* @param {string} initialStrokeColor
* @returns {Array.<string>} An array containing the font color and stroke color
* @private
*/
_getColor(color, viewFontSize, initialStrokeColor) {
let fontColor = color || '#000000'
let strokeColor = initialStrokeColor || '#ffffff'
if (viewFontSize <= this.elementOptions.scaling.label.drawThreshold) {
let opacity = Math.max(
0,
Math.min(
1,
1 - (this.elementOptions.scaling.label.drawThreshold - viewFontSize)
)
)
fontColor = util.overrideOpacity(fontColor, opacity)
strokeColor = util.overrideOpacity(strokeColor, opacity)
}
return [fontColor, strokeColor]
}
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {boolean} selected
* @param {boolean} hover
* @returns {{width: number, height: number}}
*/
getTextSize(ctx, selected = false, hover = false) {
this._processLabel(ctx, selected, hover)
return {
width: this.size.width,
height: this.size.height,
lineCount: this.lineCount
}
}
/**
* Get the current dimensions of the label
*
* @return {rect}
*/
getSize() {
let lineMargin = 2
let x = this.size.left // default values which might be overridden below
let y = this.size.top - 0.5 * lineMargin // idem
if (this.isEdgeLabel) {
const x2 = -this.size.width * 0.5
switch (this.fontOptions.align) {
case 'middle':
x = x2
y = -this.size.height * 0.5
break
case 'top':
x = x2
y = -(this.size.height + lineMargin)
break
case 'bottom':
x = x2
y = lineMargin
break
}
}
var ret = {
left: x,
top: y,
width: this.size.width,
height: this.size.height
}
return ret
}
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {boolean} selected
* @param {boolean} hover
* @param {number} [x=0]
* @param {number} [y=0]
* @param {'middle'|'hanging'} [baseline='middle']
*/
calculateLabelSize(ctx, selected, hover, x = 0, y = 0, baseline = 'middle') {
this._processLabel(ctx, selected, hover)
this.size.left = x - this.size.width * 0.5
this.size.top = y - this.size.height * 0.5
this.size.yLine = y + (1 - this.lineCount) * 0.5 * this.fontOptions.size
if (baseline === 'hanging') {
this.size.top += 0.5 * this.fontOptions.size
this.size.top += 4 // distance from node, required because we use hanging. Hanging has less difference between browsers
this.size.yLine += 4 // distance from node
}
}
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {boolean} selected
* @param {boolean} hover
* @param {string} mod
* @returns {{color, size, face, mod, vadjust, strokeWidth: *, strokeColor: (*|string|allOptions.edges.font.strokeColor|{string}|allOptions.nodes.font.strokeColor|Array)}}
*/
getFormattingValues(ctx, selected, hover, mod) {
let getValue = function(fontOptions, mod, option) {
if (mod === 'normal') {
if (option === 'mod') return ''
return fontOptions[option]
}
if (fontOptions[mod][option] !== undefined) {
// Grumbl leaving out test on undefined equals false for ""
return fontOptions[mod][option]
} else {
// Take from parent font option
return fontOptions[option]
}
}
let values = {
color: getValue(this.fontOptions, mod, 'color'),
size: getValue(this.fontOptions, mod, 'size'),
face: getValue(this.fontOptions, mod, 'face'),
mod: getValue(this.fontOptions, mod, 'mod'),
vadjust: getValue(this.fontOptions, mod, 'vadjust'),
strokeWidth: this.fontOptions.strokeWidth,
strokeColor: this.fontOptions.strokeColor
}
if (selected || hover) {
if (
mod === 'normal' &&
this.fontOptions.chooser === true &&
this.elementOptions.labelHighlightBold
) {
values.mod = 'bold'
} else {
if (typeof this.fontOptions.chooser === 'function') {
this.fontOptions.chooser(
values,
this.elementOptions.id,
selected,
hover
)
}
}
}
let fontString = ''
if (values.mod !== undefined && values.mod !== '') {
// safeguard for undefined - this happened
fontString += values.mod + ' '
}
fontString += values.size + 'px ' + values.face
ctx.font = fontString.replace(/"/g, '')
values.font = ctx.font
values.height = values.size
return values
}
/**
*
* @param {boolean} selected
* @param {boolean} hover
* @returns {boolean}
*/
differentState(selected, hover) {
return selected !== this.selectedState || hover !== this.hoverState
}
/**
* This explodes the passed text into lines and determines the width, height and number of lines.
*
* @param {CanvasRenderingContext2D} ctx
* @param {boolean} selected
* @param {boolean} hover
* @param {string} inText the text to explode
* @returns {{width, height, lines}|*}
* @private
*/
_processLabelText(ctx, selected, hover, inText) {
let splitter = new LabelSplitter(ctx, this, selected, hover)
return splitter.process(inText)
}
/**
* This explodes the label string into lines and sets the width, height and number of lines.
* @param {CanvasRenderingContext2D} ctx
* @param {boolean} selected
* @param {boolean} hover
* @private
*/
_processLabel(ctx, selected, hover) {
if (this.labelDirty === false && !this.differentState(selected, hover))
return
let state = this._processLabelText(
ctx,
selected,
hover,
this.elementOptions.label
)
if (this.fontOptions.minWdt > 0 && state.width < this.fontOptions.minWdt) {
state.width = this.fontOptions.minWdt
}
this.size.labelHeight = state.height
if (this.fontOptions.minHgt > 0 && state.height < this.fontOptions.minHgt) {
state.height = this.fontOptions.minHgt
}
this.lines = state.lines
this.lineCount = state.lines.length
this.size.width = state.width
this.size.height = state.height
this.selectedState = selected
this.hoverState = hover
this.labelDirty = false
}
/**
* Check if this label is visible
*
* @return {boolean} true if this label will be show, false otherwise
*/
visible() {
if (
this.size.width === 0 ||
this.size.height === 0 ||
this.elementOptions.label === undefined
) {
return false // nothing to display
}
let viewFontSize = this.fontOptions.size * this.body.view.scale
if (viewFontSize < this.elementOptions.scaling.label.drawThreshold - 1) {
return false // Too small or too far away to show
}
return true
}
}
export default Label