Browse Source

Network: Fix handling of multi-fonts (#3486)

* The next fix on Travis unit test failure

This is the next escalation on the war against the Travis unit tests failing (which came into being by yours truly).
By accident, I could recreate the unit test failure on my development machine. This led to a more directed effort to
squash the bug.

The insight here is that test `(window === undefined)` fails, but `(typeof window === 'undefined`)` succeeds. This undoubtedly has to do with the special status `window` has as a global object.

Changes:

- Added check on presence of `window` in `Canvas._requestNextFrame()`, fixed local source errors.
- Added catch clause in `CanvasRendered._determinePixelRatio()`
- small fix: raised timeout for the network `worldCup2014` unit test

* Preliminary refactoring in utils.js

* Added unit tests for extend routines, commenting and small fixes

* More unit tests for extend routines

* - Completed unit tests for extend routines in
- Small fixes and cleanup in `util.js`
- Removed `util.protoExtend()`, not used anywhere

* Added unit tests for known font options

* Interim save before trying out another proto chain strategy

* Fixed problem in first example #3408

* Removed silly file that shouldn't be there

* Added unit test for multi-fonts

* Comment edits

* Verufy unit tests, small adjustments for groups

* Further work on getting unit tests to work. PARTS NEED TO BE CLEANED UP!

* Further tweaks to get unit tests working

* All unit tests passing

* Fixes due to linting

* Small edits

* Removed prototype handling from font pile

* Fixes during testing examples of #3408

* Added unit test for edge labels, small fixes

* Added unit tests for shorthand string fonts; some tests still failing

* All unit tests pass

* Removed Label.parseOptions()

* Completed shorthand font tests, code cleanup, fixed choosify for edges

* Addressed review comments

* Addressed review comments, cleanup
jittering-top
wimrijnders 7 years ago
committed by Yotam Berkowitz
parent
commit
75e6263ac3
10 changed files with 1151 additions and 252 deletions
  1. +2
    -17
      lib/network/modules/EdgesHandler.js
  2. +9
    -6
      lib/network/modules/Groups.js
  3. +3
    -7
      lib/network/modules/NodesHandler.js
  4. +13
    -13
      lib/network/modules/components/Edge.js
  5. +75
    -35
      lib/network/modules/components/Node.js
  6. +3
    -2
      lib/network/modules/components/nodes/Cluster.js
  7. +245
    -145
      lib/network/modules/components/shared/Label.js
  8. +6
    -4
      lib/network/modules/components/shared/LabelSplitter.js
  9. +4
    -4
      lib/util.js
  10. +791
    -19
      test/Label.test.js

+ 2
- 17
lib/network/modules/EdgesHandler.js View File

@ -1,9 +1,7 @@
var util = require("../../util");
var DataSet = require('../../DataSet');
var DataView = require('../../DataView');
var Edge = require("./components/Edge").default;
var Label = require("./components/shared/Label").default;
/**
* Handler for Edges
@ -31,7 +29,7 @@ class EdgesHandler {
this.options = {};
this.defaultOptions = {
arrows: {
to: {enabled: false, scaleFactor:1, type: 'arrow'}, // boolean / {arrowScaleFactor:1} / {enabled: false, arrowScaleFactor:1}
to: {enabled: false, scaleFactor:1, type: 'arrow'},// boolean / {arrowScaleFactor:1} / {enabled: false, arrowScaleFactor:1}
middle: {enabled: false, scaleFactor:1, type: 'arrow'},
from: {enabled: false, scaleFactor:1, type: 'arrow'}
},
@ -193,7 +191,6 @@ class EdgesHandler {
* @param {Object} options
*/
setOptions(options) {
this.edgeOptions = options;
if (options !== undefined) {
// use the parser from the Edge class to fill in all shorthand notations
Edge.parseOptions(this.options, options, true, this.defaultOptions, true);
@ -210,8 +207,6 @@ class EdgesHandler {
// update fonts in all edges
if (options.font !== undefined) {
// use the parser from the Label class to fill in all shorthand notations
Label.parseOptions(this.options.font, options);
for (let edgeId in this.body.edges) {
if (this.body.edges.hasOwnProperty(edgeId)) {
this.body.edges[edgeId].updateLabelModule();
@ -383,17 +378,7 @@ class EdgesHandler {
* @returns {Edge}
*/
create(properties) {
// It is not at all clear why all these separate options should be passed:
//
// - this.edgeOptions is set in setOptions()
// the value of which is also added to this.options with parseOptions()
// - this.defaultOptions has been added to this.options with util.extend() in ctor
//
// So, in theory, this.options should be enough.
// The only reason I can think of for this, is that precedence is important.
// TODO: make unit tests for this, to check if edgeOptions and defaultOptions are redundant
//
return new Edge(properties, this.body, this.options, this.defaultOptions, this.edgeOptions)
return new Edge(properties, this.body, this.options, this.defaultOptions)
}
/**

+ 9
- 6
lib/network/modules/Groups.js View File

@ -75,14 +75,17 @@ class Groups {
}
/**
* get group options of a groupname. If groupname is not found, a new group
* is added.
* @param {*} groupname Can be a number, string, Date, etc.
* @return {Object} group The created group, containing all group options
* Get group options of a groupname.
* If groupname is not found, a new group may be created.
*
* @param {*} groupname Can be a number, string, Date, etc.
* @param {boolean} [shouldCreate=true] If true, create a new group
* @return {Object} The found or created group
*/
get(groupname) {
get(groupname, shouldCreate = true) {
let group = this.groups[groupname];
if (group === undefined) {
if (group === undefined && shouldCreate) {
if (this.options.useDefaultGroups === false && this.groupsArray.length > 0) {
// create new group
let index = this.groupIndex % this.groupsArray.length;

+ 3
- 7
lib/network/modules/NodesHandler.js View File

@ -1,9 +1,8 @@
let util = require("../../util");
let DataSet = require('../../DataSet');
let DataView = require('../../DataView');
var Node = require("./components/Node").default;
var Label = require("./components/shared/Label").default;
/**
* Handler for Nodes
@ -30,7 +29,6 @@ class NodesHandler {
remove: (event, params) => { this.remove(params.items); }
};
this.options = {};
this.defaultOptions = {
borderWidth: 1,
borderWidthSelected: 2,
@ -144,7 +142,7 @@ class NodesHandler {
throw 'Internal error: mass in defaultOptions of NodesHandler may not be zero or negative';
}
util.extend(this.options, this.defaultOptions);
this.options = util.bridgeObject(this.defaultOptions);
this.bindEventListeners();
}
@ -174,7 +172,6 @@ class NodesHandler {
* @param {Object} options
*/
setOptions(options) {
this.nodeOptions = options;
if (options !== undefined) {
Node.parseOptions(this.options, options);
@ -189,7 +186,6 @@ class NodesHandler {
// update the font in all nodes
if (options.font !== undefined) {
Label.parseOptions(this.options.font, options);
for (let nodeId in this.body.nodes) {
if (this.body.nodes.hasOwnProperty(nodeId)) {
this.body.nodes[nodeId].updateLabelModule();
@ -359,7 +355,7 @@ class NodesHandler {
* @returns {*}
*/
create(properties, constructorClass = Node) {
return new constructorClass(properties, this.body, this.images, this.groups, this.options, this.defaultOptions, this.nodeOptions)
return new constructorClass(properties, this.body, this.images, this.groups, this.options, this.defaultOptions)
}

+ 13
- 13
lib/network/modules/components/Edge.js View File

@ -16,9 +16,8 @@ class Edge {
* @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
* @param {Object} edgeOptions option values specific for edges.
*/
constructor(options, body, globalOptions, defaultOptions, edgeOptions) {
constructor(options, body, globalOptions, defaultOptions) {
if (body === undefined) {
throw new Error("No body provided");
}
@ -26,11 +25,9 @@ class Edge {
// 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.edgeOptions = edgeOptions;
this.body = body;
// initialize variables
@ -84,12 +81,11 @@ class Edge {
options.value = parseFloat(options.value);
}
let pile = [options, this.options, this.edgeOptions, this.defaultOptions];
let pile = [options, this.options, this.defaultOptions];
this.chooser = ComponentUtil.choosify('edge', pile);
// update label Module
this.updateLabelModule(options);
this.labelModule.propagateFonts(this.edgeOptions, options, this.defaultOptions);
let dataChanged = this.updateEdgeType();
@ -134,7 +130,9 @@ class Edge {
'to',
'title',
'value',
'width'
'width',
'font',
'chosen'
];
// only deep extend the items in the field array. These do not have shorthand.
@ -228,11 +226,7 @@ class Edge {
parentOptions.color = util.bridgeObject(globalOptions.color); // set the object back to the global options
}
// handle the font settings
if (newOptions.font !== undefined && newOptions.font !== null) {
Label.parseOptions(parentOptions.font, newOptions);
}
else if (allowDeletion === true && newOptions.font === null) {
if (allowDeletion === true && newOptions.font === null) {
parentOptions.font = util.bridgeObject(globalOptions.font); // set the object back to the global options
}
}
@ -321,7 +315,13 @@ class Edge {
* @param {Object} options
*/
updateLabelModule(options) {
let pile = [options, this.edgeOptions, this.defaultOptions];
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) {

+ 75
- 35
lib/network/modules/components/Node.js View File

@ -32,29 +32,22 @@ class Node {
* {string} label Text label for the node
* {number} x Horizontal position of the node
* {number} y Vertical position of the node
* {string} shape Node shape, available:
* "database", "circle", "ellipse",
* "box", "image", "text", "dot",
* "star", "triangle", "triangleDown",
* "square", "icon"
* {string} shape Node shape
* {string} image An image url
* {string} title An title text, can be HTML
* {string} title A title text, can be HTML
* {anytype} group A group name or number
* @param {Object} body
* @param {Network.Images} imagelist A list with images. Only needed
* when the node has an image
* @param {Groups} grouplist A list with groups. Needed for
* retrieving group options
* @param {Object} globalOptions An object with default values for
* example for the color
* @param {Object} defaultOptions
* @param {Object} nodeOptions
*
* @param {Object} body Shared state of current network instance
* @param {Network.Images} imagelist A list with images. Only needed when the node has an image
* @param {Groups} grouplist A list with groups. Needed for retrieving group options
* @param {Object} globalOptions Current global node options; these serve as defaults for the node instance
* @param {Object} defaultOptions Global default options for nodes; note that this is also the prototype
* for parameter `globalOptions`.
*/
constructor(options, body, imagelist, grouplist, globalOptions, defaultOptions, nodeOptions) {
constructor(options, body, imagelist, grouplist, globalOptions, defaultOptions) {
this.options = util.bridgeObject(globalOptions);
this.globalOptions = globalOptions;
this.defaultOptions = defaultOptions;
this.nodeOptions = nodeOptions;
this.body = body;
this.edges = []; // all edges connected to this node
@ -136,16 +129,8 @@ class Node {
if (options.size !== undefined) {this.baseSize = options.size;}
if (options.value !== undefined) {options.value = parseFloat(options.value);}
// copy group options
if (typeof options.group === 'number' || (typeof options.group === 'string' && options.group != '')) {
var groupObj = this.grouplist.get(options.group);
util.deepExtend(this.options, groupObj);
// the color object needs to be completely defined. Since groups can partially overwrite the colors, we parse it again, just in case.
this.options.color = util.parseColor(this.options.color);
}
// this transforms all shorthands into fully defined options
Node.parseOptions(this.options, options, true, this.globalOptions);
Node.parseOptions(this.options, options, true, this.globalOptions, this.grouplist);
let pile = [options, this.options, this.defaultOptions];
this.chooser = ComponentUtil.choosify('node', pile);
@ -153,7 +138,6 @@ class Node {
this._load_images();
this.updateLabelModule(options);
this.updateShape(currentShape);
this.labelModule.propagateFonts(this.nodeOptions, options, this.defaultOptions);
return (options.hidden !== undefined || options.physics !== undefined);
}
@ -199,6 +183,43 @@ class Node {
}
/**
* Copy group option values into the node options.
*
* The group options override the global node options, so the copy of group options
* must happen *after* the global node options have been set.
*
* This method must also be called also if the global node options have changed and the group options did not.
*
* @param {Object} parentOptions
* @param {Object} newOptions new values for the options, currently only passed in for check
* @param {Object} groupList
*/
static updateGroupOptions(parentOptions, newOptions, groupList) {
if (groupList === undefined) return; // No groups, nothing to do
var group = parentOptions.group;
// paranoia: the selected group is already merged into node options, check.
if (newOptions !== undefined && newOptions.group !== undefined && group !== newOptions.group) {
throw new Error("updateGroupOptions: group values in options don't match.");
}
var hasGroup = (typeof group === 'number' || (typeof group === 'string' && group != ''));
if (!hasGroup) return; // current node has no group, no need to merge
var groupObj = groupList.get(group);
// Skip merging of group font options into parent; these are required to be distinct for labels
// TODO: It might not be a good idea either to merge the rest of the options, investigate this.
util.selectiveNotDeepExtend(['font'], parentOptions, groupObj);
// the color object needs to be completely defined.
// Since groups can partially overwrite the colors, we parse it again, just in case.
parentOptions.color = util.parseColor(parentOptions.color);
}
/**
* This process all possible shorthands in the new options and makes sure that the parentOptions are fully defined.
* Static so it can also be used by the handler.
@ -207,12 +228,13 @@ class Node {
* @param {Object} newOptions
* @param {boolean} [allowDeletion=false]
* @param {Object} [globalOptions={}]
* @param {Object} [groupList]
* @static
*/
static parseOptions(parentOptions, newOptions, allowDeletion = false, globalOptions = {}) {
static parseOptions(parentOptions, newOptions, allowDeletion = false, globalOptions = {}, groupList) {
var fields = [
'color',
'font',
'fixed',
'shadow'
];
@ -248,14 +270,12 @@ class Node {
}
}
// handle the font options
if (newOptions.font !== undefined && newOptions.font !== null) {
Label.parseOptions(parentOptions.font, newOptions);
}
else if (allowDeletion === true && newOptions.font === null) {
if (allowDeletion === true && newOptions.font === null) {
parentOptions.font = util.bridgeObject(globalOptions.font); // set the object back to the global options
}
Node.updateGroupOptions(parentOptions, newOptions, groupList);
// handle the scaling options, specifically the label part
if (newOptions.scaling !== undefined) {
util.mergeOptions(parentOptions.scaling, newOptions.scaling, 'label', globalOptions.scaling);
@ -319,7 +339,27 @@ class Node {
if (this.options.label === undefined || this.options.label === null) {
this.options.label = '';
}
let pile = [options, this.nodeOptions, this.defaultOptions];
Node.updateGroupOptions(this.options, options, this.grouplist);
//
// Note:The prototype chain for this.options is:
//
// this.options -> NodesHandler.options -> NodesHandler.defaultOptions
// (also: this.globalOptions)
//
// Note that the prototypes are mentioned explicitly in the pile list below;
// WE DON'T WANT THE ORDER OF THE PROTOTYPES!!!! At least, not for font handling of labels.
// This is a good indication that the prototype usage of options is deficient.
//
var currentGroup = this.grouplist.get(this.options.group, false);
let pile = [
options, // new options
this.options, // current node options, see comment above for prototype
currentGroup, // group options, if any
this.globalOptions, // Currently set global node options
this.defaultOptions // Default global node options
];
this.labelModule.update(this.options, pile);
if (this.labelModule.baseSize !== undefined) {

+ 3
- 2
lib/network/modules/components/nodes/Cluster.js View File

@ -14,9 +14,10 @@ class Cluster extends Node {
* @param {Array.<HTMLImageElement>}imagelist
* @param {Array} grouplist
* @param {Object} globalOptions
* @param {Object} defaultOptions Global default options for nodes
*/
constructor(options, body, imagelist, grouplist, globalOptions) {
super(options, body, imagelist, grouplist, globalOptions);
constructor(options, body, imagelist, grouplist, globalOptions, defaultOptions) {
super(options, body, imagelist, grouplist, globalOptions, defaultOptions);
this.isCluster = true;
this.containedNodes = {};

+ 245
- 145
lib/network/modules/components/shared/Label.js View File

@ -2,11 +2,44 @@ let util = require('../../../../util');
let ComponentUtil = require('./ComponentUtil').default;
let LabelSplitter = require('./LabelSplitter').default;
/**
* @typedef {'bold'|'ital'|'boldital'|'mono'|'normal'} MultiFontStyle
*
* The allowed specifiers of multi-fonts.
*/
/**
* @typedef {{color:string, size:number, face:string, mod:string, vadjust:number}} MultiFontOptions
*
* The full set of options of a given multi-font.
*/
/**
* @typedef {Array.<object>} Pile
*
* Sequence of option objects, the order is significant.
* The sequence is used to determine the value of a given option.
*
* Usage principles:
*
* - All search is done in the sequence of the pile.
* - As soon as a value is found, the searching stops.
* - prototypes are totally ignored. The idea is to add option objects used as prototypes
* to the pile, in the correct order.
*/
/**
* 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
@ -16,7 +49,7 @@ class Label {
this.body = body;
this.pointToSelf = false;
this.baseSize = undefined;
this.fontOptions = {};
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}; // could be cached
this.isEdgeLabel = edgelabel;
@ -24,29 +57,26 @@ class Label {
/**
*
* @param {Object} options
* @param {boolean} [allowDeletion=false]
* @param {Object} options the options of the parent Node-instance
*/
setOptions(options, allowDeletion = false) {
this.elementOptions = options;
setOptions(options) {
this.elementOptions = options; // Reference to the options of the parent Node-instance
// We want to keep the font options separated from the node options.
// The node options have to mirror the globals when they are not overruled.
this.fontOptions = util.deepExtend({},options.font, true);
this.initFontOptions(options.font);
if (options.label !== undefined) {
this.labelDirty = true;
}
if (options.font !== undefined) {
Label.parseOptions(this.fontOptions, options, allowDeletion);
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') {
if (options.font.size !== undefined) {
this.baseSize = options.font.size;
let size = options.font.size;
if (size !== undefined) {
this.baseSize = size;
}
}
}
@ -54,21 +84,33 @@ class Label {
/**
* Init the font Options structure.
*
* @param {Object} parentOptions
* @param {Object} newOptions
* @param {boolean} [allowDeletion=false]
* @static
* 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
*/
static parseOptions(parentOptions, newOptions, allowDeletion = false) {
if (Label.parseFontString(parentOptions, newOptions.font)) {
parentOptions.vadjust = 0;
}
else if (typeof newOptions.font === 'object') {
util.fillIfDefined(parentOptions, newOptions.font, allowDeletion);
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;
}
parentOptions.size = Number(parentOptions.size);
parentOptions.vadjust = Number(parentOptions.vadjust);
// 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;
}
});
}
@ -153,6 +195,7 @@ class Label {
update(options, pile) {
this.setOptions(options, true);
this.constrain(pile);
this.propagateFonts(pile);
this.fontOptions.chooser = ComponentUtil.choosify('label', pile);
}
@ -176,142 +219,192 @@ class Label {
}
/////////////////////////////////////////////////////////
// Methods for handling options piles
// Eventually, these will be moved to a separate class
/////////////////////////////////////////////////////////
/**
* Collapse the font options for the multi-font to single objects, from
* the chain of option objects passed.
* Add the font members of the passed list of option objects to the pile.
*
* If an option for a specific multi-font is not present, the parent
* option is checked for the given option.
* @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.
*
* NOTE: naming of 'groupOptions' is a misnomer; the actual value passed
* is the new values to set from setOptions().
* @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 {Object} options
* @param {Object} groupOptions
* @param {Object} defaultOptions
* @param {Pile} pile pile of option objects to use
* @returns {object} object with all current own basic font properties
* @private
*/
propagateFonts(options, groupOptions, defaultOptions) {
if (!this.fontOptions.multi) return;
/**
* Resolve the font options path.
* If valid, return a reference to the object in question.
* Otherwise, just return null.
*
* @param {Object} options base object to determine path from
* @param {'bold'|'ital'|'boldital'|'mono'|'normal'} [mod=undefined] if present, sub path for the mod-font
* @returns {Object|null}
*/
var pathP = function(options, mod) {
if (!options || !options.font) return null;
var opt = options.font;
if (mod) {
if (!opt[mod]) return null;
opt = opt[mod];
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;
}
return opt;
};
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;
}
/**
* Get property value from options.font[mod][property] if present.
* If mod not passed, use property value from options.font[property].
*
* @param {Label.options} options
* @param {'bold'|'ital'|'boldital'|'mono'|'normal'} mod
* @param {string} property
* @return {*|null} value if found, null otherwise.
*/
var getP = function(options, mod, property) {
let opt = pathP(options, mod);
if (opt && opt.hasOwnProperty(property)) {
return opt[property];
if (multiFont.hasOwnProperty(option)) {
return multiFont[option];
}
}
}
return null;
};
// 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 + "'");
}
let mods = [ 'bold', 'ital', 'boldital', 'mono' ];
for (const mod of mods) {
let modOptions = this.fontOptions[mod];
let modDefaults = defaultOptions.font[mod];
/**
* 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
if (Label.parseFontString(modOptions, pathP(options, mod))) {
modOptions.vadjust = this.fontOptions.vadjust;
modOptions.mod = modDefaults.mod;
} else {
for (let i = 0; i < optionNames.length; ++i) {
let mod = optionNames[i];
result[mod] = this.getFontOption(pile, multiName, mod);
}
// We need to be crafty about loading the modded fonts. We want as
// much 'natural' versatility as we can get, so a simple global
// change propagates in an expected way, even if not stictly logical.
// 'face' has a special exception for mono, since we probably
// don't want to sync to the base font face.
modOptions.face =
getP(options , mod, 'face') ||
getP(groupOptions, mod, 'face') ||
(mod === 'mono'? modDefaults.face:null ) ||
getP(groupOptions, null, 'face') ||
this.fontOptions.face
;
// 'color' follows the standard flow
modOptions.color =
getP(options , mod, 'color') ||
getP(groupOptions, mod, 'color') ||
getP(groupOptions, null, 'color') ||
this.fontOptions.color
;
// 'mode' follows the standard flow
modOptions.mod =
getP(options , mod, 'mod') ||
getP(groupOptions, mod, 'mod') ||
getP(groupOptions, null, 'mod') ||
modDefaults.mod
;
// It's important that we size up defaults similarly if we're
// using default faces unless overriden. We want to preserve the
// ratios closely - but if faces have changed, all bets are off.
let ratio;
// NOTE: Following condition always fails, because modDefaults
// has no explicit font property. This is deliberate, see
// var's 'NodesHandler.defaultOptions.font[mod]'.
// However, I want to keep the original logic while refactoring;
// it appears to be working fine even if ratio is never set.
// TODO: examine if this is a bug, fix if necessary.
//
if ((modOptions.face === modDefaults.face) &&
(this.fontOptions.face === defaultOptions.font.face)) {
ratio = this.fontOptions.size / Number(defaultOptions.font.size);
}
return result;
}
/////////////////////////////////////////////////////////
// End methods for handling options piles
/////////////////////////////////////////////////////////
modOptions.size =
getP(options , mod, 'size') ||
getP(groupOptions, mod, 'size') ||
(ratio? modDefaults.size * ratio: null) || // Scale the mod size using the same ratio
getP(groupOptions, null, 'size') ||
this.fontOptions.size
;
modOptions.vadjust =
getP(options , mod, 'vadjust') ||
getP(groupOptions, mod, 'vadjust') ||
(ratio? modDefaults.vadjust * Math.round(ratio): null) || // Scale it using the same ratio
this.fontOptions.vadjust
;
/**
* 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);
@ -546,13 +639,13 @@ class Label {
* @returns {{color, size, face, mod, vadjust, strokeWidth: *, strokeColor: (*|string|allOptions.edges.font.strokeColor|{string}|allOptions.nodes.font.strokeColor|Array)}}
*/
getFormattingValues(ctx, selected, hover, mod) {
var getValue = function(fontOptions, mod, option) {
let getValue = function(fontOptions, mod, option) {
if (mod === "normal") {
if (option === 'mod' ) return "";
return fontOptions[option];
}
if (fontOptions[mod][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
@ -578,7 +671,14 @@ class Label {
}
}
}
ctx.font = (values.mod + " " + values.size + "px " + values.face).replace(/"/g, "");
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;

+ 6
- 4
lib/network/modules/components/shared/LabelSplitter.js View File

@ -71,6 +71,8 @@ class LabelSplitter {
return this.lines.finalize();
}
var font = this.parent.fontOptions;
// Normalize the end-of-line's to a single representation - order important
text = text.replace(/\r\n/g, '\n'); // Dos EOL's
text = text.replace(/\r/g, '\n'); // Mac EOL's
@ -81,10 +83,10 @@ class LabelSplitter {
let nlLines = String(text).split('\n');
let lineCount = nlLines.length;
if (this.parent.elementOptions.font.multi) {
if (font.multi) {
// Multi-font case: styling tags active
for (let i = 0; i < lineCount; i++) {
let blocks = this.splitBlocks(nlLines[i], this.parent.elementOptions.font.multi);
let blocks = this.splitBlocks(nlLines[i], font.multi);
// Post: Sequences of tabs and spaces are reduced to single space
if (blocks === undefined) continue;
@ -94,7 +96,7 @@ class LabelSplitter {
continue;
}
if (this.parent.fontOptions.maxWdt > 0) {
if (font.maxWdt > 0) {
// widthConstraint.maximum defined
//console.log('Running widthConstraint multi, max: ' + this.fontOptions.maxWdt);
for (let j = 0; j < blocks.length; j++) {
@ -115,7 +117,7 @@ class LabelSplitter {
}
} else {
// Single-font case
if (this.parent.fontOptions.maxWdt > 0) {
if (font.maxWdt > 0) {
// widthConstraint.maximum defined
// console.log('Running widthConstraint normal, max: ' + this.fontOptions.maxWdt);
for (let i = 0; i < lineCount; i++) {

+ 4
- 4
lib/util.js View File

@ -251,8 +251,8 @@ exports.selectiveDeepExtend = function (props, a, b, allowDeletion = false) {
/**
* Extend object `a` with properties of object `b`, ignoring properties which are explicitly specified
* to be excluded.
* Extend object `a` with properties of object `b`, ignoring properties which are explicitly
* specified to be excluded.
*
* The properties of `b` are considered for copying.
* Properties which are themselves objects are are also extended.
@ -1210,7 +1210,7 @@ exports.isValidRGBA = function (rgba) {
* @returns {*}
*/
exports.selectiveBridgeObject = function (fields, referenceObject) {
if (typeof referenceObject == "object") {
if (referenceObject !== null && typeof referenceObject === "object") { // !!! typeof null === 'object'
var objectTo = Object.create(referenceObject);
for (var i = 0; i < fields.length; i++) {
if (referenceObject.hasOwnProperty(fields[i])) {
@ -1234,7 +1234,7 @@ exports.selectiveBridgeObject = function (fields, referenceObject) {
* @returns {*}
*/
exports.bridgeObject = function (referenceObject) {
if (typeof referenceObject == "object") {
if (referenceObject !== null && typeof referenceObject === "object") { // !!! typeof null === 'object'
var objectTo = Object.create(referenceObject);
if (referenceObject instanceof Element) {
// Avoid bridging DOM objects

+ 791
- 19
test/Label.test.js View File

@ -2,14 +2,20 @@
* TODO - add tests for:
* ====
*
* - !!! good test case with the tags for max width
* - pathological cases of spaces (and other whitespace!)
* - html unclosed or unopened tags
* - html tag combinations with no font defined (e.g. bold within mono)
* - Unit tests for bad font shorthands.
* Currently, only "size[px] name color" is valid, always 3 items with this exact spacing.
* All other combinations should either be rejected as error or handled gracefully.
*/
var assert = require('assert')
var Label = require('../lib/network/modules/components/shared/Label').default;
var NodesHandler = require('../lib/network/modules/NodesHandler').default;
var util = require('../lib/util');
var jsdom_global = require('jsdom-global');
var vis = require('../dist/vis');
var Network = vis.network;
/**************************************************************
* Dummy class definitions for minimal required functionality.
@ -128,12 +134,10 @@ describe('Network Label', function() {
];
/**************************************************************
* Expected Results
**************************************************************/
var normal_expected = [{
// In first item, width/height kept in for reference
width: 120,
@ -315,6 +319,20 @@ describe('Network Label', function() {
* End Expected Results
**************************************************************/
before(function() {
this.jsdom_global = jsdom_global(
"<div id='mynetwork'></div>",
{ skipWindowCheck: true}
);
this.container = document.getElementById('mynetwork');
});
after(function() {
this.jsdom_global();
});
it('parses normal text labels', function (done) {
var label = new Label({}, getOptions());
@ -418,7 +436,760 @@ describe('Network Label', function() {
});
it('compresses spaces in multifont', function (done) {
describe('Multi-Fonts', function() {
class HelperNode {
constructor(network) {
this.nodes = network.body.nodes;
}
fontOption(index) {
return this.nodes[index].labelModule.fontOptions;
};
modBold(index) {
return this.fontOption(index).bold;
};
}
describe('Node Labels', function() {
function createNodeNetwork(newOptions) {
var dataNodes = [
{id: 0, label: '<b>0</b>'},
{id: 1, label: '<b>1</b>'},
{id: 2, label: '<b>2</b>', group: 'group1'},
{id: 3, label: '<b>3</b>',
font: {
bold: { color: 'green' },
}
},
{id: 4, label: '<b>4</b>', group: 'group1',
font: {
bold: { color: 'green' },
}
},
];
// create a network
var container = document.getElementById('mynetwork');
var data = {
nodes: new vis.DataSet(dataNodes),
edges: []
};
var options = {
nodes: {
font: {
multi: true
}
},
groups: {
group1: {
font: { color: 'red' },
},
group2: {
font: { color: 'white' },
},
},
};
if (newOptions !== undefined) {
util.deepExtend(options, newOptions);
}
var network = new vis.Network(container, data, options);
return [network, data, options];
}
/**
* Check that setting options for multi-font works as expected
*
* - using multi-font 'bold' for test, the rest should work analogously
* - using multi-font option 'color' for test, the rest should work analogously
*/
it('respects the font option precedence', function (done) {
var [network, data, options] = createNodeNetwork();
var h = new HelperNode(network);
assert.equal(h.modBold(0).color, '#343434'); // Default value
assert.equal(h.modBold(1).color, '#343434'); // Default value
assert.equal(h.modBold(2).color, 'red'); // Group value overrides default
assert.equal(h.modBold(3).color, 'green'); // Local value overrides default
assert.equal(h.modBold(4).color, 'green'); // Local value overrides group
done();
});
it('handles dynamic data and option updates', function (done) {
var [network, data, options] = createNodeNetwork();
var h = new HelperNode(network);
//
// Change some node values dynamically
//
data.nodes.update([
{id: 1, group: 'group2'},
{id: 4, font: { bold: { color: 'orange'}}},
]);
assert.equal(h.modBold(0).color, '#343434'); // unchanged
assert.equal(h.modBold(1).color, 'white'); // new group value
assert.equal(h.modBold(3).color, 'green'); // unchanged
assert.equal(h.modBold(4).color, 'orange'); // new local value
//
// Change group options dynamically
//
network.setOptions({
groups: {
group1: {
font: { color: 'brown' },
},
},
});
assert.equal(h.modBold(0).color, '#343434'); // unchanged
assert.equal(h.modBold(1).color, 'white'); // Unchanged
assert.equal(h.modBold(2).color, 'brown'); // New group values
assert.equal(h.modBold(3).color, 'green'); // unchanged
assert.equal(h.modBold(4).color, 'orange'); // unchanged
network.setOptions({
nodes: {
font: {
multi: true,
bold: {
color: 'black'
}
}
},
});
assert.equal(h.modBold(0).color, 'black'); // nodes default
assert.equal(h.modBold(1).color, 'black'); // more specific bold value overrides group value
assert.equal(h.modBold(2).color, 'black'); // idem
assert.equal(h.modBold(3).color, 'green'); // unchanged
assert.equal(h.modBold(4).color, 'orange'); // unchanged
network.setOptions({
groups: {
group1: {
font: { bold: {color: 'brown'} },
},
},
});
assert.equal(h.modBold(0).color, 'black'); // nodes default
assert.equal(h.modBold(1).color, 'black'); // more specific bold value overrides group value
assert.equal(h.modBold(2).color, 'brown'); // bold group value overrides bold node value
assert.equal(h.modBold(3).color, 'green'); // unchanged
assert.equal(h.modBold(4).color, 'orange'); // unchanged
done();
});
it('handles normal font values in default options', function (done) {
var newOptions = {
nodes: {
font: {
color: 'purple' // Override the default value
}
},
};
var [network, data, options] = createNodeNetwork(newOptions);
var h = new HelperNode(network);
assert.equal(h.modBold(0).color, 'purple'); // Nodes value
assert.equal(h.modBold(1).color, 'purple'); // Nodes value
assert.equal(h.modBold(2).color, 'red'); // Group value overrides nodes
assert.equal(h.modBold(3).color, 'green'); // Local value overrides all
assert.equal(h.modBold(4).color, 'green'); // Idem
done();
});
it('handles multi-font values in default options/groups', function (done) {
var newOptions = {
nodes: {
font: {
color: 'purple' // This set value should be overridden
}
},
};
newOptions.nodes.font.bold = { color: 'yellow'};
newOptions.groups = {
group1: {
font: { bold: { color: 'red'}}
}
};
var [network, data, options] = createNodeNetwork(newOptions);
var h = new HelperNode(network);
assert(options.nodes.font.multi);
assert.equal(h.modBold(0).color, 'yellow'); // bold value
assert.equal(h.modBold(1).color, 'yellow'); // bold value
assert.equal(h.modBold(2).color, 'red'); // Group value overrides nodes
assert.equal(h.modBold(3).color, 'green'); // Local value overrides all
assert.equal(h.modBold(4).color, 'green'); // Idem
done();
});
}); // Node Labels
describe('Edge Labels', function() {
function createEdgeNetwork(newOptions) {
var dataNodes = [
{id: 1, label: '1'},
{id: 2, label: '2'},
{id: 3, label: '3'},
{id: 4, label: '4'},
];
var dataEdges = [
{id: 1, from: 1, to: 2, label: '<b>1</b>'},
{id: 2, from: 1, to: 4, label: '<b>2</b>',
font: {
bold: { color: 'green' },
}
},
{id: 3, from: 2, to: 3, label: '<b>3</b>',
font: {
bold: { color: 'green' },
}
},
];
// create a network
var container = document.getElementById('mynetwork');
var data = {
nodes: new vis.DataSet(dataNodes),
edges: new vis.DataSet(dataEdges),
};
var options = {
edges: {
font: {
multi: true
}
},
};
if (newOptions !== undefined) {
util.deepExtend(options, newOptions);
}
var network = new vis.Network(container, data, options);
return [network, data, options];
}
class HelperEdge {
constructor(network) {
this.edges = network.body.edges;
}
fontOption(index) {
return this.edges[index].labelModule.fontOptions;
};
modBold(index) {
return this.fontOption(index).bold;
};
}
/**
* Check that setting options for multi-font works as expected
*
* - using multi-font 'bold' for test, the rest should work analogously
* - using multi-font option 'color' for test, the rest should work analogously
* - edges have no groups
*/
it('respects the font option precedence', function (done) {
var [network, data, options] = createEdgeNetwork();
var h = new HelperEdge(network);
assert.equal(h.modBold(1).color, '#343434'); // Default value
assert.equal(h.modBold(2).color, 'green'); // Local value overrides default
assert.equal(h.modBold(3).color, 'green'); // Local value overrides group
done();
});
it('handles dynamic data and option updates', function (done) {
var [network, data, options] = createEdgeNetwork();
var h = new HelperEdge(network);
data.edges.update([
{id: 3, font: { bold: { color: 'orange'}}},
]);
assert.equal(h.modBold(1).color, '#343434'); // unchanged
assert.equal(h.modBold(2).color, 'green'); // unchanged
assert.equal(h.modBold(3).color, 'orange'); // new local value
network.setOptions({
edges: {
font: {
multi: true,
bold: {
color: 'black'
}
}
},
});
assert.equal(h.modBold(1).color, 'black'); // more specific bold value overrides group value
assert.equal(h.modBold(2).color, 'green'); // unchanged
assert.equal(h.modBold(3).color, 'orange'); // unchanged
done();
});
it('handles font values in default options', function (done) {
var newOptions = {
edges: {
font: {
color: 'purple' // Override the default value
}
},
};
var [network, data, options] = createEdgeNetwork(newOptions);
var h = new HelperEdge(network);
assert.equal(h.modBold(1).color, 'purple'); // Nodes value
assert.equal(h.modBold(2).color, 'green'); // Local value overrides all
assert.equal(h.modBold(3).color, 'green'); // Idem
done();
});
}); // Edge Labels
describe('Shorthand Font Options', function() {
var testFonts = {
'default': {color: '#343434', face: 'arial' , size: 14},
'monodef': {color: '#343434', face: 'monospace', size: 15},
'font1' : {color: '#010101', face: 'Font1' , size: 1},
'font2' : {color: '#020202', face: 'Font2' , size: 2},
'font3' : {color: '#030303', face: 'Font3' , size: 3},
'font4' : {color: '#040404', face: 'Font4' , size: 4},
'font5' : {color: '#050505', face: 'Font5' , size: 5},
'font6' : {color: '#060606', face: 'Font6' , size: 6},
'font7' : {color: '#070707', face: 'Font7' , size: 7},
};
function checkFont(opt, expectedLabel) {
var expected = testFonts[expectedLabel];
util.forEach(expected, (item, key) => {
assert.equal(opt[key], item);
});
};
function createNetwork() {
var dataNodes = [
{id: 1, label: '1'},
{id: 2, label: '2', group: 'group1'},
{id: 3, label: '3', group: 'group2'},
{id: 4, label: '4', font: '5px Font5 #050505'},
];
var dataEdges = [];
// create a network
var container = document.getElementById('mynetwork');
var data = {
nodes: new vis.DataSet(dataNodes),
edges: new vis.DataSet(dataEdges),
};
var options = {
nodes: {
font: {
multi: true,
bold: '1 Font1 #010101',
ital: '2 Font2 #020202',
}
},
groups: {
group1: {
font: '3 Font3 #030303'
},
group2: {
font: {
bold: '4 Font4 #040404'
}
}
}
};
var network = new vis.Network(container, data, options);
return [network, data];
}
it('handles shorthand options correctly', function (done) {
var [network, d