diff --git a/.gitignore b/.gitignore
index c145381d..d9a5e809 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,5 +13,6 @@ npm-debug.log
.settings/
.directory
-# vim temporary files
+# temporary files
.*.sw[op]
+.commits.tmp
diff --git a/HISTORY.md b/HISTORY.md
index 23e8c72f..5cacd289 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,6 +1,68 @@
# vis.js history
http://visjs.org
+## 2017-05-21, version 4.20.0
+
+### General
+
+- FIX #2934: Replacing all ES6 imports with CJS require calls (#3063)
+- Add command line options to mocha for running tests (#3064)
+- Added documentation on how labels are used (#2873)
+- FIX: Fix typo in PR template (#2908)
+- FIX #2912: updated moment.js (#2925)
+- Added @wimrijnders to the support team (#2886)
+
+### Network
+
+- FIX: Fixes for loading images into image nodes (#2964)
+- FIX #3025: Added check on mission var 'options', refactoring. (#3055)
+- FIX #3057: Use get() to get data from DataSet/View instead of directly accessing member \_data. (#3069)
+- FIX #3065: Avoid overriding standard context method ellipse() (#3072)
+- FIX #2922: bold label for selected ShapeBase classes (#2924)
+- FIX #2952: Pre-render node images for interpolation (#3010)
+- FIX #1735: Fix for exploding directed network, first working version; refactored hierarchical state in LayoutEngine.(#3017)
+- Refactoring of Label.propagateFonts() (#3052)
+- FIX #2894: Set CircleImageBase.imageObjAlt always when options change (#3053)
+- FIX #3047: Label.getFormattingValues() fix option fallback to main font for mod-fonts (#3054)
+- FIX #2938: Fix handling of node id's in saveAndLoad example (#2943)
+- FIX: Refactoring in Canvas.js (#3030)
+- FIX #2968: Fix placement label for dot shape (#3018)
+- FIX #2994: select edge with id zero (#2996)
+- FIX #1847, #2436: Network: use separate refresh indicator in NodeBase, instead of width… (#2885)
+- Fix #2914: Use option edges.chosen if present in global options (#2917)
+- FIX #2940: Gephi consolidate double assignment of node title (#2962)
+- FIX 2936: Fix check for nodes not present in EdgesHandler (#2963)
+- FEAT: Reduce the time-complexity of the network initial positioning (#2759)
+
+### Timeline / Graph2D
+
+- FEAT: Add support for multiple class names in utils add/remove class methods (#3079)
+- FEAT: Adds 'showTooltips' option to override popups displayed for items with titles (#3046)
+- FIX #2818: LineGraph: Add an existingItemsMap to check if items are new or not before skipping (#3075)
+- FEAT #2835: Improve timeline stack performance (#2848, #3078)
+- FIX #3032: mouseup and mousedown events (#3059)
+- FIX #2421: Fix click and doubleclick events on items (#2988)
+- FEAT #1405, #1715, #3002: Implementation of a week scale feature (#3009)
+- FIX #397: Eliminate repeatedly fired `rangechanged` events on mousewheel (#2989)
+- FIX #2939: Add check for parent existence when changing group in Item.setData (#2985)
+- FIX #2877: Add check for empty groupIds array and get full list from data set (#2986)
+- FIX #2614: Timeline docs border overlaps (#2992)
+- FIX: Doubleclick add (#2987)
+- FIX #2679: Cannot read property 'hasOwnProperty' of null (#2973)
+- FEAT #2863: Drag and drop custom fields (#2872)
+- FEAT #2834: Control over the drop event (#2974)
+- FIX #2918: Remove usages of elementsCensor (#2947)
+- FEAT #2948: Rolling mode offset (#2950)
+- FEAT #2805: Add callback functions to moveTo, zoomIn, zoomOut and setWindow (#2870)
+- FIX: Do not corrupt class names at high zoom levels (#2909)
+- FIX #2888: Fix error in class names (#2911)
+- FIX #2835: Visible items bug (#2878)
+
+### Graph3D
+
+- FEAT: Configurable minimum and maximum sizes for dot-size graphs (#2849)
+
+
## 2017-03-19, version 4.19.1
### General
diff --git a/docs/graph3d/index.html b/docs/graph3d/index.html
index 69d28ad6..902cd4eb 100644
--- a/docs/graph3d/index.html
+++ b/docs/graph3d/index.html
@@ -272,7 +272,7 @@ var options = {
The following options are available.
-
+
Name
Type
@@ -311,69 +311,83 @@ var options = {
The color of the axis lines and the text along the axis.
-
-
backgroundColor
+
+
backgroundColor
string or Object
-
{fill: 'white', stroke: 'gray', strokeWidth: 1}
+
Object
The background color for the main area of the chart.
Can be either a simple HTML color string, for example: 'red' or '#00cc00',
or an object with the following properties.
-
-
backgroundColor.fill
+
+
backgroundColor.fill
string
'white'
The chart fill color, as an HTML color string.
-
-
backgroundColor.stroke
+
+
backgroundColor.stroke
string
'gray'
The color of the chart border, as an HTML color string.
-
backgroundColor.strokeWidth
+
+
backgroundColor.strokeWidth
number
1
The border width, in pixels.
-
-
cameraPosition
+
+
cameraPosition
+
Object
Object
-
{horizontal: 1.0, vertical: 0.5, distance: 1.7}
Set the initial rotation and position of the camera.
- The object cameraPosition contains three parameters:
- horizontal, vertical, and distance.
- Parameter horizontal is a value in radians and can have any
- value (but normally in the range of 0 and 2*Pi).
- Parameter vertical is a value in radians between 0 and 0.5*Pi.
- Parameter distance is the (normalized) distance from the
- camera to the center of the graph, in the range of 0.71 to 5.0. A
- larger distance puts the graph further away, making it smaller.
All parameters are optional.
+
+
cameraPosition.horizontal
+
number
+
1.0
+
Value in radians. It can have any
+ value, but is normally in the range of 0 and 2*Pi.
+
+
+
cameraPosition.vertical
+
number
+
0.5
+
Value in radians between 0 and 0.5*Pi.
+
+
+
cameraPosition.distance
+
number
+
1.7
+
The (normalized) distance from the
+ camera to the center of the graph, in the range of 0.71 to 5.0. A
+ larger distance puts the graph further away, making it smaller.
+
When dataColor is a string, it will set the color for both border and fill color of dots and bars. Applicable for styles dot-size, bar-size, and line. When an object, it can contain the properties descibed below.
-
-
dataColor.fill
+
+
dataColor.fill
string
'#7DC1FF'
The fill color of the dots or bars. Applicable when using styles dot-size, bar-size, or line.
-
-
dataColor.stroke
+
+
dataColor.stroke
string
'#3267D2'
The border color of the dots or bars. Applicable when using styles dot-size or bar-size.
-
-
dataColor.strokeWidth
+
+
dataColor.strokeWidth
number
1
The line width of dots, bars and lines. Applicable for all styles.
Nodes can be in clusters. Clusters can also be in clusters. This function returns and array of
- nodeIds
- showing where the node is.
Example:
+ nodeIds showing where the node is.
+
+
+ If any nodeId in the chain, especially the first passed in as a parameter, is not present in
+ the current nodes list, an empty array is returned.
+
+
getConnectedNodes(String
- nodeId or edgeId)
+ nodeId or edgeId, [String direction])
Returns: Array
-
Returns an array of nodeIds of the all the nodes that are directly connected to this node. If you supply an edgeId,
- vis will first match the id to nodes. If no match is found, it will search in the edgelist and return an array: [fromId, toId].
+
Returns an array of nodeIds of all the nodes that are directly connected to this node or edge.
+ For a node id, returns an array with the id's of the connected nodes.
+ If optional parameter direction is set to string 'from', only parent nodes are returned.
+ If direction is set to 'to', only child nodes are returned.
+ Any other value or undefined returns both parent and child nodes.
+
+ For an edge id, returns an array: [fromId, toId].
+ Parameter direction is ignored for edges.
The barnesHut physics model (which is enabled by default) is based on an inverted gravity model. By
- increasing the mass of a node, you increase it's repulsion. Values lower than 1 are not recommended.
+ increasing the mass of a node, you increase it's repulsion.
+
+ Values between 0 and 1 are not recommended.
+ Negative or zero values are not allowed. These will generate a console error and will be set to 1.
diff --git a/docs/timeline/index.html b/docs/timeline/index.html
index 293f4cca..2bd2a8c4 100644
--- a/docs/timeline/index.html
+++ b/docs/timeline/index.html
@@ -261,6 +261,13 @@ var items = new vis.DataSet([
Styles.
+
+
align
+
String
+
no
+
This field is optional. If set this overrides the global align configuration option for this item.
+
+
content
String
@@ -729,12 +736,24 @@ function (option, path) {
-
-
itemsAlwaysDraggable
+
+
itemsAlwaysDraggable
+
boolean or Object
+
Object
+
When a boolean, applies the value only to itemsAlwaysDraggable.item.
+
+
+
itemsAlwaysDraggable.item
boolean
false
If true, all items in the Timeline are draggable without being selected. If false, only the selected item(s) are draggable.
+
+
itemsAlwaysDraggable.range
+
boolean
+
false
+
If true, range of all items in the Timeline is draggable without being selected. If false, range is only draggable for the selected item(s). Only applicable when option itemsAlwaysDraggable.item is set true.
The itemsAlwaysDraggable option allows to drag items around without first selecting them. When itemsAlwaysDraggable.range is set to true, the range can be changed without selection as well.
+
+
+
+
+
diff --git a/lib/graph3d/DataGroup.js b/lib/graph3d/DataGroup.js
new file mode 100644
index 00000000..8f2a615b
--- /dev/null
+++ b/lib/graph3d/DataGroup.js
@@ -0,0 +1,307 @@
+var DataSet = require('../DataSet');
+var DataView = require('../DataView');
+var Point3d = require('./Point3d');
+var Range = require('./Range');
+
+
+/**
+ * Creates a container for all data of one specific 3D-graph.
+ *
+ * On construction, the container is totally empty; the data
+ * needs to be initialized with method initializeData().
+ * Failure to do so will result in the following exception begin thrown
+ * on instantiation of Graph3D:
+ *
+ * Error: Array, DataSet, or DataView expected
+ *
+ * @constructor
+ */
+function DataGroup() {
+ this.dataTable = null; // The original data table
+}
+
+
+/**
+ * Initializes the instance from the passed data.
+ *
+ * Calculates minimum and maximum values and column index values.
+ *
+ * The graph3d instance is used internally to access the settings for
+ * the given instance.
+ * TODO: Pass settings only instead.
+ *
+ * @param {Graph3D} graph3d Reference to the calling Graph3D instance.
+ * @param {Array | DataSet | DataView} rawData The data containing the items for
+ * the Graph.
+ * @param {Number} style Style Number
+ */
+DataGroup.prototype.initializeData = function(graph3d, rawData, style) {
+ var me = this;
+
+ // unsubscribe from the dataTable
+ if (this.dataSet) {
+ this.dataSet.off('*', this._onChange);
+ }
+
+ if (rawData === undefined)
+ return;
+
+ if (Array.isArray(rawData)) {
+ rawData = new DataSet(rawData);
+ }
+
+ var data;
+ if (rawData instanceof DataSet || rawData instanceof DataView) {
+ data = rawData.get();
+ }
+ else {
+ throw new Error('Array, DataSet, or DataView expected');
+ }
+
+ if (data.length == 0)
+ return;
+
+ this.dataSet = rawData;
+ this.dataTable = data;
+
+ // subscribe to changes in the dataset
+ this._onChange = function () {
+ me.setData(me.dataSet);
+ };
+ this.dataSet.on('*', this._onChange);
+
+ // determine the location of x,y,z,value,filter columns
+ this.colX = 'x';
+ this.colY = 'y';
+ this.colZ = 'z';
+
+
+ var withBars = graph3d.hasBars(style);
+
+ // determine barWidth from data
+ if (withBars) {
+ if (graph3d.defaultXBarWidth !== undefined) {
+ this.xBarWidth = graph3d.defaultXBarWidth;
+ }
+ else {
+ this.xBarWidth = this.getSmallestDifference(data, this.colX) || 1;
+ }
+
+ if (graph3d.defaultYBarWidth !== undefined) {
+ this.yBarWidth = graph3d.defaultYBarWidth;
+ }
+ else {
+ this.yBarWidth = this.getSmallestDifference(data, this.colY) || 1;
+ }
+ }
+
+ // calculate minima and maxima
+ this._initializeRange(data, this.colX, graph3d, withBars);
+ this._initializeRange(data, this.colY, graph3d, withBars);
+ this._initializeRange(data, this.colZ, graph3d, false);
+
+ if (data[0].hasOwnProperty('style')) {
+ this.colValue = 'style';
+ var valueRange = this.getColumnRange(data, this.colValue);
+ this._setRangeDefaults(valueRange, graph3d.defaultValueMin, graph3d.defaultValueMax);
+ this.valueRange = valueRange;
+ }
+};
+
+
+/**
+ * Collect the range settings for the given data column.
+ *
+ * This internal method is intended to make the range
+ * initalization more generic.
+ *
+ * TODO: if/when combined settings per axis defined, get rid of this.
+ *
+ * @private
+ *
+ * @param {'x'|'y'|'z'} column The data column to process
+ * @param {Graph3D} graph3d Reference to the calling Graph3D instance;
+ * required for access to settings
+ */
+DataGroup.prototype._collectRangeSettings = function(column, graph3d) {
+ var index = ['x', 'y', 'z'].indexOf(column);
+
+ if (index == -1) {
+ throw new Error('Column \'' + column + '\' invalid');
+ }
+
+ var upper = column.toUpperCase();
+
+ return {
+ barWidth : this[column + 'BarWidth'],
+ min : graph3d['default' + upper + 'Min'],
+ max : graph3d['default' + upper + 'Max'],
+ step : graph3d['default' + upper + 'Step'],
+ range_label: column + 'Range', // Name of instance field to write to
+ step_label : column + 'Step' // Name of instance field to write to
+ };
+}
+
+
+/**
+ * Initializes the settings per given column.
+ *
+ * TODO: if/when combined settings per axis defined, rewrite this.
+ *
+ * @private
+ *
+ * @param {DataSet | DataView} data The data containing the items for the Graph
+ * @param {'x'|'y'|'z'} column The data column to process
+ * @param {Graph3D} graph3d Reference to the calling Graph3D instance;
+ * required for access to settings
+ * @param {Boolean} withBars True if initializing for bar graph
+ */
+DataGroup.prototype._initializeRange = function(data, column, graph3d, withBars) {
+ var NUMSTEPS = 5;
+ var settings = this._collectRangeSettings(column, graph3d);
+
+ var range = this.getColumnRange(data, column);
+ if (withBars && column != 'z') { // Safeguard for 'z'; it doesn't have a bar width
+ range.expand(settings.barWidth / 2);
+ }
+
+ this._setRangeDefaults(range, settings.min, settings.max);
+ this[settings.range_label] = range;
+ this[settings.step_label ] = (settings.step !== undefined) ? settings.step : range.range()/NUMSTEPS;
+}
+
+
+/**
+ * Creates a list with all the different values in the data for the given column.
+ *
+ * If no data passed, use the internal data of this instance.
+ *
+ * @param {'x'|'y'|'z'} column The data column to process
+ * @param {DataSet|DataView|undefined} data The data containing the items for the Graph
+ *
+ * @returns {Array} All distinct values in the given column data, sorted ascending.
+ */
+DataGroup.prototype.getDistinctValues = function(column, data) {
+ if (data === undefined) {
+ data = this.dataTable;
+ }
+
+ var values = [];
+
+ for (var i = 0; i < data.length; i++) {
+ var value = data[i][column] || 0;
+ if (values.indexOf(value) === -1) {
+ values.push(value);
+ }
+ }
+
+ return values.sort(function(a,b) { return a - b; });
+};
+
+
+/**
+ * Determine the smallest difference between the values for given
+ * column in the passed data set.
+ *
+ * @param {DataSet|DataView|undefined} data The data containing the items for the Graph
+ * @param {'x'|'y'|'z'} column The data column to process
+ *
+ * @returns {Number|null} Smallest difference value or
+ * null, if it can't be determined.
+ */
+DataGroup.prototype.getSmallestDifference = function(data, column) {
+ var values = this.getDistinctValues(data, column);
+
+ // Get all the distinct diffs
+ // Array values is assumed to be sorted here
+ var smallest_diff = null;
+
+ for (var i = 1; i < values.length; i++) {
+ var diff = values[i] - values[i - 1];
+
+ if (smallest_diff == null || smallest_diff > diff ) {
+ smallest_diff = diff;
+ }
+ }
+
+ return smallest_diff;
+}
+
+
+/**
+ * Get the absolute min/max values for the passed data column.
+ *
+ * @param {DataSet|DataView|undefined} data The data containing the items for the Graph
+ * @param {'x'|'y'|'z'} column The data column to process
+ *
+ * @returns {Range} A Range instance with min/max members properly set.
+ */
+DataGroup.prototype.getColumnRange = function(data, column) {
+ var range = new Range();
+
+ // Adjust the range so that it covers all values in the passed data elements.
+ for (var i = 0; i < data.length; i++) {
+ var item = data[i][column];
+ range.adjust(item);
+ }
+
+ return range;
+};
+
+
+/**
+ * Determines the number of rows in the current data.
+ *
+ * @returns {Number}
+ */
+DataGroup.prototype.getNumberOfRows = function() {
+ return this.dataTable.length;
+}
+
+
+/**
+ * Set default values for range
+ *
+ * The default values override the range values, if defined.
+ *
+ * Because it's possible that only defaultMin or defaultMax is set, it's better
+ * to pass in a range already set with the min/max set from the data. Otherwise,
+ * it's quite hard to process the min/max properly.
+ */
+DataGroup.prototype._setRangeDefaults = function (range, defaultMin, defaultMax) {
+ if (defaultMin !== undefined) {
+ range.min = defaultMin;
+ }
+
+ if (defaultMax !== undefined) {
+ range.max = defaultMax;
+ }
+
+ // This is the original way that the default min/max values were adjusted.
+ // TODO: Perhaps it's better if an error is thrown if the values do not agree.
+ // But this will change the behaviour.
+ if (range.max <= range.min) range.max = range.min + 1;
+};
+
+
+DataGroup.prototype.getDataTable = function() {
+ return this.dataTable;
+};
+
+
+DataGroup.prototype.getDataSet = function() {
+ return this.dataSet;
+};
+
+
+/**
+ * Reload the data
+ */
+DataGroup.prototype.reload = function() {
+ if (this.dataTable) {
+ this.setData(this.dataTable);
+ }
+};
+
+
+module.exports = DataGroup;
diff --git a/lib/graph3d/Filter.js b/lib/graph3d/Filter.js
index 41d734a3..56f0c453 100644
--- a/lib/graph3d/Filter.js
+++ b/lib/graph3d/Filter.js
@@ -3,12 +3,12 @@ var DataView = require('../DataView');
/**
* @class Filter
*
- * @param {DataSet} data The google data table
+ * @param {DataGroup} dataGroup the data group
* @param {Number} column The index of the column to be filtered
* @param {Graph} graph The graph
*/
-function Filter (data, column, graph) {
- this.data = data;
+function Filter (dataGroup, column, graph) {
+ this.data = dataGroup.getDataSet();
this.column = column;
this.graph = graph; // the parent graph
@@ -16,12 +16,7 @@ function Filter (data, column, graph) {
this.value = undefined;
// read all distinct values and select the first one
- this.values = graph.getDistinctValues(data.get(), this.column);
-
- // sort both numeric and string values correctly
- this.values.sort(function (a, b) {
- return a > b ? 1 : a < b ? -1 : 0;
- });
+ this.values = dataGroup.getDistinctValues(this.column);
if (this.values.length > 0) {
this.selectValue(0);
diff --git a/lib/graph3d/Graph3d.js b/lib/graph3d/Graph3d.js
index 29371ff7..eb4d61a6 100755
--- a/lib/graph3d/Graph3d.js
+++ b/lib/graph3d/Graph3d.js
@@ -1,4 +1,5 @@
-var Emitter = require('emitter-component'); var DataSet = require('../DataSet');
+var Emitter = require('emitter-component');
+var DataSet = require('../DataSet');
var DataView = require('../DataView');
var util = require('../util');
var Point3d = require('./Point3d');
@@ -9,6 +10,7 @@ var Slider = require('./Slider');
var StepNumber = require('./StepNumber');
var Range = require('./Range');
var Settings = require('./Settings');
+var DataGroup = require('./DataGroup');
/// enumerate the available styles
@@ -148,7 +150,7 @@ function Graph3d(container, data, options) {
// create variables and set default values
this.containerElement = container;
- this.dataTable = null; // The original data table
+ this.dataGroup = new DataGroup();
this.dataPoints = null; // The table with point objects
// create a frame and canvas
@@ -303,11 +305,7 @@ Graph3d.prototype._convertTranslationToScreen = function(translation) {
/**
* Calculate the translations and screen positions of all points
*/
-Graph3d.prototype._calcTranslations = function(points, sort) {
- if (sort === undefined) {
- sort = true;
- }
-
+Graph3d.prototype._calcTranslations = function(points) {
for (var i = 0; i < points.length; i++) {
var point = points[i];
point.trans = this._convertPointToTranslation(point.point);
@@ -318,10 +316,6 @@ Graph3d.prototype._calcTranslations = function(points, sort) {
point.dist = this.showPerspective ? transBottom.length() : -transBottom.z;
}
- if (!sort) {
- return;
- }
-
// sort the points on depth of their (x,y) position (not on z)
var sortDepth = function (a, b) {
return b.dist - a.dist;
@@ -330,78 +324,6 @@ Graph3d.prototype._calcTranslations = function(points, sort) {
};
-Graph3d.prototype.getNumberOfRows = function(data) {
- return data.length;
-}
-
-
-Graph3d.prototype.getNumberOfColumns = function(data) {
- var counter = 0;
- for (var column in data[0]) {
- if (data[0].hasOwnProperty(column)) {
- counter++;
- }
- }
- return counter;
-}
-
-
-Graph3d.prototype.getDistinctValues = function(data, column) {
- var distinctValues = [];
- for (var i = 0; i < data.length; i++) {
- if (distinctValues.indexOf(data[i][column]) == -1) {
- distinctValues.push(data[i][column]);
- }
- }
- return distinctValues.sort(function(a,b) { return a - b; });
-}
-
-
-/**
- * Determine the smallest difference between the values for given
- * column in the passed data set.
- *
- * @returns {Number|null} Smallest difference value or
- * null, if it can't be determined.
- */
-Graph3d.prototype.getSmallestDifference = function(data, column) {
- var values = this.getDistinctValues(data, column);
- var diffs = [];
-
- // Get all the distinct diffs
- // Array values is assumed to be sorted here
- var smallest_diff = null;
-
- for (var i = 1; i < values.length; i++) {
- var diff = values[i] - values[i - 1];
-
- if (smallest_diff == null || smallest_diff > diff ) {
- smallest_diff = diff;
- }
- }
-
- return smallest_diff;
-}
-
-
-/**
- * Get the absolute min/max values for the passed data column.
- *
- * @returns {Range} A Range instance with min/max members properly set.
- */
-Graph3d.prototype.getColumnRange = function(data,column) {
- var range = new Range();
-
- // Adjust the range so that it covers all values in the passed data elements.
- for (var i = 0; i < data.length; i++) {
- var item = data[i][column];
- range.adjust(item);
- }
-
- return range;
-};
-
-
/**
* Check if the state is consistent for the use of the value field.
*
@@ -418,6 +340,7 @@ Graph3d.prototype._checkValueField = function (data) {
return; // No need to check further
}
+
// Following field must be present for the current graph style
if (this.colValue === undefined) {
throw new Error('Expected data to have '
@@ -437,150 +360,76 @@ Graph3d.prototype._checkValueField = function (data) {
};
-/**
- * Set default values for range
- *
- * The default values override the range values, if defined.
- *
- * Because it's possible that only defaultMin or defaultMax is set, it's better
- * to pass in a range already set with the min/max set from the data. Otherwise,
- * it's quite hard to process the min/max properly.
- */
-Graph3d.prototype._setRangeDefaults = function (range, defaultMin, defaultMax) {
- if (defaultMin !== undefined) {
- range.min = defaultMin;
- }
-
- if (defaultMax !== undefined) {
- range.max = defaultMax;
- }
-
- // This is the original way that the default min/max values were adjusted.
- // TODO: Perhaps it's better if an error is thrown if the values do not agree.
- // But this will change the behaviour.
- if (range.max <= range.min) range.max = range.min + 1;
-};
-
-
-/**
- * Initialize the data from the data table. Calculate minimum and maximum values
- * and column index values
- * @param {Array | DataSet | DataView} rawData The data containing the items for
- * the Graph.
- * @param {Number} style Style Number
- */
-Graph3d.prototype._dataInitialize = function (rawData, style) {
- var me = this;
-
- // unsubscribe from the dataTable
- if (this.dataSet) {
- this.dataSet.off('*', this._onChange);
- }
+Graph3d.prototype._initializeData = function(rawData, style) {
+ this.dataGroup.initializeData(this, rawData, style);
- if (rawData === undefined)
- return;
+ // Transfer min/max values to the Graph3d instance.
+ // TODO: later on, all min/maxes of all datagroups will be combined here
+ this.xRange = this.dataGroup.xRange;
+ this.yRange = this.dataGroup.yRange;
+ this.zRange = this.dataGroup.zRange;
+ this.valueRange = this.dataGroup.valueRange;
- if (Array.isArray(rawData)) {
- rawData = new DataSet(rawData);
- }
+ // Values currently needed but which need to be sorted out for
+ // the multiple graph case.
+ this.xStep = this.dataGroup.xStep;
+ this.yStep = this.dataGroup.yStep;
+ this.zStep = this.dataGroup.zStep;
+ this.xBarWidth = this.dataGroup.xBarWidth;
+ this.yBarWidth = this.dataGroup.yBarWidth;
+ this.colX = this.dataGroup.colX;
+ this.colY = this.dataGroup.colY;
+ this.colZ = this.dataGroup.colZ;
+ this.colValue = this.dataGroup.colValue;
- var data;
- if (rawData instanceof DataSet || rawData instanceof DataView) {
- data = rawData.get();
- }
- else {
- throw new Error('Array, DataSet, or DataView expected');
- }
+ // Check if a filter column is provided
+ var data = this.dataGroup.getDataTable();
- if (data.length == 0)
- return;
-
- this.dataSet = rawData;
- this.dataTable = data;
-
- // subscribe to changes in the dataset
- this._onChange = function () {
- me.setData(me.dataSet);
- };
- this.dataSet.on('*', this._onChange);
-
- // determine the location of x,y,z,value,filter columns
- this.colX = 'x';
- this.colY = 'y';
- this.colZ = 'z';
-
-
- var withBars = this.style == Graph3d.STYLE.BAR ||
- this.style == Graph3d.STYLE.BARCOLOR ||
- this.style == Graph3d.STYLE.BARSIZE;
-
- // determine barWidth from data
- if (withBars) {
- if (this.defaultXBarWidth !== undefined) {
- this.xBarWidth = this.defaultXBarWidth;
- }
- else {
- this.xBarWidth = this.getSmallestDifference(data, this.colX) || 1;
- }
-
- if (this.defaultYBarWidth !== undefined) {
- this.yBarWidth = this.defaultYBarWidth;
- }
- else {
- this.yBarWidth = this.getSmallestDifference(data, this.colY) || 1;
- }
- }
-
- // calculate minimums and maximums
- var NUMSTEPS = 5;
-
- var xRange = this.getColumnRange(data, this.colX);
- if (withBars) {
- xRange.expand(this.xBarWidth / 2);
- }
- this._setRangeDefaults(xRange, this.defaultXMin, this.defaultXMax);
- this.xRange = xRange;
- this.xStep = (this.defaultXStep !== undefined) ? this.defaultXStep : xRange.range()/NUMSTEPS;
-
- var yRange = this.getColumnRange(data, this.colY);
- if (withBars) {
- yRange.expand(this.yBarWidth / 2);
- }
- this._setRangeDefaults(yRange, this.defaultYMin, this.defaultYMax);
- this.yRange = yRange;
- this.yStep = (this.defaultYStep !== undefined) ? this.defaultYStep : yRange.range()/NUMSTEPS;
-
- var zRange = this.getColumnRange(data, this.colZ);
- this._setRangeDefaults(zRange, this.defaultZMin, this.defaultZMax);
- this.zRange = zRange;
- this.zStep = (this.defaultZStep !== undefined) ? this.defaultZStep : zRange.range()/NUMSTEPS;
-
- if (data[0].hasOwnProperty('style')) {
- this.colValue = 'style';
- var valueRange = this.getColumnRange(data,this.colValue);
- this._setRangeDefaults(valueRange, this.defaultValueMin, this.defaultValueMax);
- this.valueRange = valueRange;
- }
-
-
- // check if a filter column is provided
- // Needs to be started after zRange is defined
if (data[0].hasOwnProperty('filter')) {
// Only set this field if it's actually present
this.colFilter = 'filter';
+ var me = this;
if (this.dataFilter === undefined) {
- this.dataFilter = new Filter(rawData, this.colFilter, this);
+ this.dataFilter = new Filter(this.dataGroup, this.colFilter, this);
this.dataFilter.setOnLoadCallback(function() {me.redraw();});
}
}
-
-
+
// set the scale dependent on the ranges.
this._setScale();
};
+/**
+ * Return all data values as a list of Point3d objects
+ */
+Graph3d.prototype.getDataPoints = function(data) {
+ var dataPoints = [];
+
+ for (var i = 0; i < data.length; i++) {
+ var point = new Point3d();
+ point.x = data[i][this.colX] || 0;
+ point.y = data[i][this.colY] || 0;
+ point.z = data[i][this.colZ] || 0;
+ point.data = data[i];
+
+ if (this.colValue !== undefined) {
+ point.value = data[i][this.colValue] || 0;
+ }
+
+ var obj = {};
+ obj.point = point;
+ obj.bottom = new Point3d(point.x, point.y, this.zRange.min);
+ obj.trans = undefined;
+ obj.screen = undefined;
+
+ dataPoints.push(obj);
+ }
+
+ return dataPoints;
+};
+
/**
* Filter the data based on the current filter
@@ -598,60 +447,29 @@ Graph3d.prototype._getDataPoints = function (data) {
if (this.style === Graph3d.STYLE.GRID ||
this.style === Graph3d.STYLE.SURFACE) {
- // copy all values from the google data table to a matrix
+ // copy all values from the data table to a matrix
// the provided values are supposed to form a grid of (x,y) positions
// create two lists with all present x and y values
- var dataX = [];
- var dataY = [];
- for (i = 0; i < this.getNumberOfRows(data); i++) {
- x = data[i][this.colX] || 0;
- y = data[i][this.colY] || 0;
-
- if (dataX.indexOf(x) === -1) {
- dataX.push(x);
- }
- if (dataY.indexOf(y) === -1) {
- dataY.push(y);
- }
- }
+ var dataX = this.dataGroup.getDistinctValues(this.colX, data);
+ var dataY = this.dataGroup.getDistinctValues(this.colY, data);
- var sortNumber = function (a, b) {
- return a - b;
- };
- dataX.sort(sortNumber);
- dataY.sort(sortNumber);
+ dataPoints = this.getDataPoints(data);
// create a grid, a 2d matrix, with all values.
var dataMatrix = []; // temporary data matrix
- for (i = 0; i < data.length; i++) {
- x = data[i][this.colX] || 0;
- y = data[i][this.colY] || 0;
- z = data[i][this.colZ] || 0;
+ for (i = 0; i < dataPoints.length; i++) {
+ obj = dataPoints[i];
// TODO: implement Array().indexOf() for Internet Explorer
- var xIndex = dataX.indexOf(x);
- var yIndex = dataY.indexOf(y);
+ var xIndex = dataX.indexOf(obj.point.x);
+ var yIndex = dataY.indexOf(obj.point.y);
if (dataMatrix[xIndex] === undefined) {
dataMatrix[xIndex] = [];
}
- var point3d = new Point3d();
- point3d.x = x;
- point3d.y = y;
- point3d.z = z;
- point3d.data = data[i];
-
- obj = {};
- obj.point = point3d;
- obj.trans = undefined;
- obj.screen = undefined;
- obj.bottom = new Point3d(x, y, this.zRange.min);
-
dataMatrix[xIndex][yIndex] = obj;
-
- dataPoints.push(obj);
}
// fill in the pointers to the neighbors.
@@ -670,39 +488,22 @@ Graph3d.prototype._getDataPoints = function (data) {
}
else { // 'dot', 'dot-line', etc.
this._checkValueField(data);
+ dataPoints = this.getDataPoints(data);
- // copy all values from the google data table to a list with Point3d objects
- for (i = 0; i < data.length; i++) {
- point = new Point3d();
- point.x = data[i][this.colX] || 0;
- point.y = data[i][this.colY] || 0;
- point.z = data[i][this.colZ] || 0;
- point.data = data[i];
-
- if (this.colValue !== undefined) {
- point.value = data[i][this.colValue] || 0;
- }
-
- obj = {};
- obj.point = point;
- obj.bottom = new Point3d(point.x, point.y, this.zRange.min);
- obj.trans = undefined;
- obj.screen = undefined;
-
- if (this.style === Graph3d.STYLE.LINE) {
+ if (this.style === Graph3d.STYLE.LINE) {
+ // Add next member points for line drawing
+ for (i = 0; i < dataPoints.length; i++) {
if (i > 0) {
- // Add next point for line drawing
- dataPoints[i - 1].pointNext = obj;
+ dataPoints[i - 1].pointNext = dataPoints[i];;
}
}
-
- dataPoints.push(obj);
}
}
return dataPoints;
};
+
/**
* Create the main frame for the Graph3d.
*
@@ -854,7 +655,7 @@ Graph3d.prototype.getCameraPosition = function() {
*/
Graph3d.prototype._readData = function(data) {
// read the data
- this._dataInitialize(data, this.style);
+ this._initializeData(data, this.style);
if (this.dataFilter) {
@@ -863,7 +664,7 @@ Graph3d.prototype._readData = function(data) {
}
else {
// no filtering. load all data
- this.dataPoints = this._getDataPoints(this.dataTable);
+ this.dataPoints = this._getDataPoints(this.dataGroup.getDataTable());
}
// draw the filter
@@ -901,9 +702,7 @@ Graph3d.prototype.setOptions = function (options) {
this._setSize(this.width, this.height);
// re-load the data
- if (this.dataTable) {
- this.setData(this.dataTable);
- }
+ this.dataGroup.reload();
// start animation when option is true
if (this.animationAutoStart && this.dataFilter) {
@@ -1138,6 +937,7 @@ Graph3d.prototype._redrawLegend = function() {
ctx.fillText(label, right, bottom + this.margin);
};
+
/**
* Redraw the filter
*/
@@ -2335,6 +2135,20 @@ Graph3d.prototype._dataPointFromXY = function (x, y) {
return closestDataPoint;
};
+
+/**
+ * Determine if the given style has bars
+ *
+ * @param {number} style the style to check
+ * @returns {boolean} true if bar style, false otherwise
+ */
+Graph3d.prototype.hasBars = function(style) {
+ return style == Graph3d.STYLE.BAR ||
+ style == Graph3d.STYLE.BARCOLOR ||
+ style == Graph3d.STYLE.BARSIZE;
+};
+
+
/**
* Display a tooltip for given data point
* @param {Object} dataPoint
diff --git a/lib/network/CachedImage.js b/lib/network/CachedImage.js
index 6e027f08..ad640017 100644
--- a/lib/network/CachedImage.js
+++ b/lib/network/CachedImage.js
@@ -66,6 +66,10 @@ class CachedImage {
* This methods takes the resizing out of the drawing loop, in order to
* reduce performance overhead.
*
+ * TODO: The code assumes that a 2D context can always be gotten. This is
+ * not necessarily true! OTOH, if not true then usage of this class
+ * is senseless.
+ *
* @private
*/
_fillMipMap() {
diff --git a/lib/network/Network.js b/lib/network/Network.js
index 4d194f5f..d7528f9a 100644
--- a/lib/network/Network.js
+++ b/lib/network/Network.js
@@ -55,13 +55,27 @@ function Network(container, data, options) {
};
util.extend(this.options, this.defaultOptions);
- // containers for nodes and edges
+ /**
+ * Containers for nodes and edges.
+ *
+ * 'edges' and 'nodes' contain the full definitions of all the network elements.
+ * 'nodeIndices' and 'edgeIndices' contain the id's of the active elements.
+ *
+ * The distinction is important, because a defined node need not be active, i.e.
+ * visible on the canvas. This happens in particular when clusters are defined, in
+ * that case there will be nodes and edges not displayed.
+ * The bottom line is that all code with actions related to visibility, *must* use
+ * 'nodeIndices' and 'edgeIndices', not 'nodes' and 'edges' directly.
+ */
this.body = {
container: container,
+
+ // See comment above for following fields
nodes: {},
nodeIndices: [],
edges: {},
edgeIndices: [],
+
emitter: {
on: this.on.bind(this),
off: this.off.bind(this),
diff --git a/lib/network/dotparser.js b/lib/network/dotparser.js
index 6e38c9e8..ee3f1997 100644
--- a/lib/network/dotparser.js
+++ b/lib/network/dotparser.js
@@ -10,6 +10,29 @@
* @return {Object} graph An object containing two parameters:
* {Object[]} nodes
* {Object[]} edges
+ *
+ * -------------------------------------------
+ * TODO
+ * ====
+ *
+ * For label handling, this is an incomplete implementation. From docs (quote #3015):
+ *
+ * > the escape sequences "\n", "\l" and "\r" divide the label into lines, centered,
+ * > left-justified, and right-justified, respectively.
+ *
+ * Source: http://www.graphviz.org/content/attrs#kescString
+ *
+ * > As another aid for readability, dot allows double-quoted strings to span multiple physical
+ * > lines using the standard C convention of a backslash immediately preceding a newline
+ * > character
+ * > In addition, double-quoted strings can be concatenated using a '+' operator.
+ * > As HTML strings can contain newline characters, which are used solely for formatting,
+ * > the language does not allow escaped newlines or concatenation operators to be used
+ * > within them.
+ *
+ * - Currently, only '\\n' is handled
+ * - Note that text explicitly says 'labels'; the dot parser currently handles escape
+ * sequences in **all** strings.
*/
function parseDOT (data) {
dot = data;
@@ -358,9 +381,14 @@ function getToken() {
if (c === '"') {
next();
while (c != '' && (c != '"' || (c === '"' && nextPreview() === '"'))) {
- token += c;
- if (c === '"') { // skip the escape character
+ if (c === '"') { // skip the escape character
+ token += c;
+ next();
+ } else if (c === '\\' && nextPreview() === 'n') { // Honor a newline escape sequence
+ token += '\n';
next();
+ } else {
+ token += c;
}
next();
}
diff --git a/lib/network/modules/Canvas.js b/lib/network/modules/Canvas.js
index ee2fd387..25ef90d0 100644
--- a/lib/network/modules/Canvas.js
+++ b/lib/network/modules/Canvas.js
@@ -188,9 +188,8 @@ class Canvas {
this.frame.canvas.appendChild(noCanvas);
}
else {
- let ctx = this.frame.canvas.getContext("2d");
- this._setPixelRatio(ctx);
- this.frame.canvas.getContext("2d").setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0);
+ this._setPixelRatio();
+ this.setTransform();
}
// add the frame to the container element
@@ -257,9 +256,19 @@ class Canvas {
let oldHeight = this.frame.canvas.height;
// update the pixel ratio
- let ctx = this.frame.canvas.getContext("2d");
+ //
+ // NOTE: Comment in following is rather inconsistent; this is the ONLY place in the code
+ // where it is assumed that the pixel ratio could change at runtime.
+ // The only way I can think of this happening is a rotating screen or tablet; but then
+ // there should be a mechanism for reloading the data (TODO: check if this is present).
+ //
+ // If the assumption is true (i.e. pixel ratio can change at runtime), then *all* usage
+ // of pixel ratio must be overhauled for this.
+ //
+ // For the time being, I will humor the assumption here, and in the rest of the code assume it is
+ // constant.
let previousRatio = this.pixelRatio; // we cache this because the camera state storage needs the old value
- this._setPixelRatio(ctx);
+ this._setPixelRatio();
if (width != this.options.width || height != this.options.height || this.frame.style.width != width || this.frame.style.height != height) {
this._getCameraState(previousRatio);
@@ -324,11 +333,23 @@ class Canvas {
};
+ getContext() {
+ return this.frame.canvas.getContext("2d");
+ }
+
+
/**
+ * Determine the pixel ratio for various browsers.
+ *
* @private
*/
- _setPixelRatio(ctx) {
- this.pixelRatio = (window.devicePixelRatio || 1) / (ctx.webkitBackingStorePixelRatio ||
+ _determinePixelRatio() {
+ let ctx = this.getContext();
+ if (ctx === undefined) {
+ throw "Could not get canvax context";
+ }
+
+ return (window.devicePixelRatio || 1) / (ctx.webkitBackingStorePixelRatio ||
ctx.mozBackingStorePixelRatio ||
ctx.msBackingStorePixelRatio ||
ctx.oBackingStorePixelRatio ||
@@ -336,6 +357,29 @@ class Canvas {
}
+ /**
+ * Lazy determination of pixel ratio.
+ *
+ * @private
+ */
+ _setPixelRatio() {
+ this.pixelRatio = this._determinePixelRatio();
+ }
+
+
+ /**
+ * Set the transform in the contained context, based on its pixelRatio
+ */
+ setTransform() {
+ let ctx = this.getContext();
+ if (ctx === undefined) {
+ throw "Could not get canvax context";
+ }
+
+ ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0);
+ }
+
+
/**
* Convert the X coordinate in DOM-space (coordinate point in browser relative to the container div) to
* the X coordinate in canvas-space (the simulation sandbox, which the camera looks upon)
diff --git a/lib/network/modules/CanvasRenderer.js b/lib/network/modules/CanvasRenderer.js
index a4064e02..27ad7194 100644
--- a/lib/network/modules/CanvasRenderer.js
+++ b/lib/network/modules/CanvasRenderer.js
@@ -16,7 +16,6 @@ class CanvasRenderer {
this.requiresTimeout = true;
this.renderingActive = false;
this.renderRequests = 0;
- this.pixelRatio = undefined;
this.allowRedraw = true;
this.dragging = false;
@@ -61,7 +60,7 @@ class CanvasRenderer {
clearTimeout(this.renderTimer);
}
else {
- cancelAnimationFrame(this.renderTimer);
+ window.cancelAnimationFrame(this.renderTimer);
}
this.body.emitter.off();
});
@@ -138,20 +137,15 @@ class CanvasRenderer {
this.body.emitter.emit("initRedraw");
this.redrawRequested = false;
- let ctx = this.canvas.frame.canvas.getContext('2d');
// when the container div was hidden, this fixes it back up!
if (this.canvas.frame.canvas.width === 0 || this.canvas.frame.canvas.height === 0) {
this.canvas.setSize();
}
- this.pixelRatio = (window.devicePixelRatio || 1) / (ctx.webkitBackingStorePixelRatio ||
- ctx.mozBackingStorePixelRatio ||
- ctx.msBackingStorePixelRatio ||
- ctx.oBackingStorePixelRatio ||
- ctx.backingStorePixelRatio || 1);
+ this.canvas.setTransform();
- ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0);
+ let ctx = this.canvas.getContext();
// clear the canvas
let w = this.canvas.frame.canvas.clientWidth;
@@ -198,21 +192,14 @@ class CanvasRenderer {
/**
* Redraw all nodes
- * The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d');
+ *
* @param {CanvasRenderingContext2D} ctx
* @param {Boolean} [alwaysShow]
* @private
*/
_resizeNodes() {
- let ctx = this.canvas.frame.canvas.getContext('2d');
- if (this.pixelRatio === undefined) {
- this.pixelRatio = (window.devicePixelRatio || 1) / (ctx.webkitBackingStorePixelRatio ||
- ctx.mozBackingStorePixelRatio ||
- ctx.msBackingStorePixelRatio ||
- ctx.oBackingStorePixelRatio ||
- ctx.backingStorePixelRatio || 1);
- }
- ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0);
+ this.canvas.setTransform();
+ let ctx = this.canvas.getContext();
ctx.save();
ctx.translate(this.body.view.translation.x, this.body.view.translation.y);
ctx.scale(this.body.view.scale, this.body.view.scale);
@@ -235,8 +222,8 @@ class CanvasRenderer {
/**
* Redraw all nodes
- * The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d');
- * @param {CanvasRenderingContext2D} ctx
+ *
+ * @param {CanvasRenderingContext2D} ctx 2D context of a HTML canvas
* @param {Boolean} [alwaysShow]
* @private
*/
@@ -283,8 +270,7 @@ class CanvasRenderer {
/**
* Redraw all edges
- * The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d');
- * @param {CanvasRenderingContext2D} ctx
+ * @param {CanvasRenderingContext2D} ctx 2D context of a HTML canvas
* @private
*/
_drawEdges(ctx) {
diff --git a/lib/network/modules/Clustering.js b/lib/network/modules/Clustering.js
index b039a0fc..f295c162 100644
--- a/lib/network/modules/Clustering.js
+++ b/lib/network/modules/Clustering.js
@@ -689,22 +689,32 @@ class ClusterEngine {
/**
* Get the stack clusterId's that a certain node resides in. cluster A -> cluster B -> cluster C -> node
- * @param nodeId
+ *
+ * If a node can't be found in the chain, return an empty array.
+ *
+ * @param {string|number} nodeId
* @returns {Array}
*/
findNode(nodeId) {
let stack = [];
let max = 100;
let counter = 0;
+ let node;
while (this.clusteredNodes[nodeId] !== undefined && counter < max) {
- stack.push(this.body.nodes[nodeId].id);
+ node = this.body.nodes[nodeId]
+ if (node === undefined) return [];
+ stack.push(node.id);
+
nodeId = this.clusteredNodes[nodeId].clusterId;
counter++;
}
- stack.push(this.body.nodes[nodeId].id);
- stack.reverse();
+ node = this.body.nodes[nodeId]
+ if (node === undefined) return [];
+ stack.push(node.id);
+
+ stack.reverse();
return stack;
}
diff --git a/lib/network/modules/EdgesHandler.js b/lib/network/modules/EdgesHandler.js
index d62ba685..81841525 100644
--- a/lib/network/modules/EdgesHandler.js
+++ b/lib/network/modules/EdgesHandler.js
@@ -4,6 +4,7 @@ var DataView = require('../../DataView');
var Edge = require("./components/Edge").default;
var Label = require("./components/shared/Label").default;
+var LayoutEngine = require("./LayoutEngine").default; // For access to LayoutEngine.getStaticType()
class EdgesHandler {
constructor(body, images, groups) {
@@ -115,11 +116,11 @@ class EdgesHandler {
bindEventListeners() {
// this allows external modules to force all dynamic curves to turn static.
- this.body.emitter.on("_forceDisableDynamicCurves", (type) => {
+ this.body.emitter.on("_forceDisableDynamicCurves", (type, emit = true) => {
if (type === 'dynamic') {
type = 'continuous';
}
- let emitChange = false;
+ let dataChanged = false;
for (let edgeId in this.body.edges) {
if (this.body.edges.hasOwnProperty(edgeId)) {
let edge = this.body.edges[edgeId];
@@ -137,18 +138,25 @@ class EdgesHandler {
else {
edge.setOptions({smooth: {type: type}});
}
- emitChange = true;
+ dataChanged = true;
}
}
}
}
}
- if (emitChange === true) {
+ if (emit === true && dataChanged === true) {
this.body.emitter.emit("_dataChanged");
}
});
// this is called when options of EXISTING nodes or edges have changed.
+ //
+ // NOTE: Not true, called when options have NOT changed, for both existing as well as new nodes.
+ // See update() for logic.
+ // TODO: Verify and examine the consequences of this. It might still trigger when
+ // non-option fields have changed, but then reconnecting edges is still useless.
+ // Alternatively, it might also be called when edges are removed.
+ //
this.body.emitter.on("_dataUpdated", () => {
this.reconnectEdges();
});
@@ -247,6 +255,7 @@ class EdgesHandler {
this.add(ids, true);
}
+ this.body.emitter.emit('_adjustEdgesForHierarchicalLayout');
if (doNotEmit === false) {
this.body.emitter.emit("_dataChanged");
}
@@ -274,6 +283,8 @@ class EdgesHandler {
edges[id] = this.create(data);
}
+ this.body.emitter.emit('_adjustEdgesForHierarchicalLayout');
+
if (doNotEmit === false) {
this.body.emitter.emit("_dataChanged");
}
@@ -308,6 +319,7 @@ class EdgesHandler {
}
if (dataChanged === true) {
+ this.body.emitter.emit('_adjustEdgesForHierarchicalLayout');
this.body.emitter.emit("_dataChanged");
}
else {
diff --git a/lib/network/modules/InteractionHandler.js b/lib/network/modules/InteractionHandler.js
index c0b8dade..663608cd 100644
--- a/lib/network/modules/InteractionHandler.js
+++ b/lib/network/modules/InteractionHandler.js
@@ -170,60 +170,53 @@ class InteractionHandler {
/**
+ * Select and deselect nodes depending current selection change.
+ *
+ * For changing nodes, select/deselect events are fired.
+ *
+ * NOTE: For a given edge, if one connecting node is deselected and with the same
+ * click the other node is selected, no events for the edge will fire.
+ * It was selected and it will remain selected.
+ *
+ * TODO: This is all SelectionHandler calls; the method should be moved to there.
*
* @param pointer
* @param add
*/
checkSelectionChanges(pointer, event, add = false) {
- let previouslySelectedEdgeCount = this.selectionHandler._getSelectedEdgeCount();
- let previouslySelectedNodeCount = this.selectionHandler._getSelectedNodeCount();
let previousSelection = this.selectionHandler.getSelection();
- let selected;
+ let selected = false;
if (add === true) {
selected = this.selectionHandler.selectAdditionalOnPoint(pointer);
}
else {
selected = this.selectionHandler.selectOnPoint(pointer);
}
- let selectedEdgesCount = this.selectionHandler._getSelectedEdgeCount();
- let selectedNodesCount = this.selectionHandler._getSelectedNodeCount();
let currentSelection = this.selectionHandler.getSelection();
- let {nodesChanged, edgesChanged} = this._determineIfDifferent(previousSelection, currentSelection);
- let nodeSelected = false;
+ // See NOTE in method comment for the reason to do it like this
+ let deselectedItems = this._determineDifference(previousSelection, currentSelection);
+ let selectedItems = this._determineDifference(currentSelection , previousSelection);
- if (selectedNodesCount - previouslySelectedNodeCount > 0) { // node was selected
- this.selectionHandler._generateClickEvent('selectNode', event, pointer);
- selected = true;
- nodeSelected = true;
- }
- else if (nodesChanged === true && selectedNodesCount > 0) {
- this.selectionHandler._generateClickEvent('deselectNode', event, pointer, previousSelection);
- this.selectionHandler._generateClickEvent('selectNode', event, pointer);
- nodeSelected = true;
+ if (deselectedItems.edges.length > 0) {
+ this.selectionHandler._generateClickEvent('deselectEdge', event, pointer, previousSelection);
selected = true;
}
- else if (selectedNodesCount - previouslySelectedNodeCount < 0) { // node was deselected
+
+ if (deselectedItems.nodes.length > 0) {
this.selectionHandler._generateClickEvent('deselectNode', event, pointer, previousSelection);
selected = true;
}
-
- // handle the selected edges
- if (selectedEdgesCount - previouslySelectedEdgeCount > 0 && nodeSelected === false) { // edge was selected
- this.selectionHandler._generateClickEvent('selectEdge', event, pointer);
+ if (selectedItems.nodes.length > 0) {
+ this.selectionHandler._generateClickEvent('selectNode', event, pointer);
selected = true;
}
- else if (selectedEdgesCount > 0 && edgesChanged === true) {
- this.selectionHandler._generateClickEvent('deselectEdge', event, pointer, previousSelection);
+
+ if (selectedItems.edges.length > 0) {
this.selectionHandler._generateClickEvent('selectEdge', event, pointer);
selected = true;
}
- else if (selectedEdgesCount - previouslySelectedEdgeCount < 0) { // edge was deselected
- this.selectionHandler._generateClickEvent('deselectEdge', event, pointer, previousSelection);
- selected = true;
- }
-
// fire the select event if anything has been selected or deselected
if (selected === true) { // select or unselect
@@ -233,38 +226,31 @@ class InteractionHandler {
/**
- * This function checks if the nodes and edges previously selected have changed.
- * @param previousSelection
- * @param currentSelection
- * @returns {{nodesChanged: boolean, edgesChanged: boolean}}
+ * Remove all node and edge id's from the first set that are present in the second one.
+ *
+ * @param firstSet
+ * @param secondSet
+ * @returns {{nodes: array, edges: array}}
* @private
*/
- _determineIfDifferent(previousSelection,currentSelection) {
- let nodesChanged = false;
- let edgesChanged = false;
-
- for (let i = 0; i < previousSelection.nodes.length; i++) {
- if (currentSelection.nodes.indexOf(previousSelection.nodes[i]) === -1) {
- nodesChanged = true;
- }
- }
- for (let i = 0; i < currentSelection.nodes.length; i++) {
- if (previousSelection.nodes.indexOf(previousSelection.nodes[i]) === -1) {
- nodesChanged = true;
- }
- }
- for (let i = 0; i < previousSelection.edges.length; i++) {
- if (currentSelection.edges.indexOf(previousSelection.edges[i]) === -1) {
- edgesChanged = true;
- }
- }
- for (let i = 0; i < currentSelection.edges.length; i++) {
- if (previousSelection.edges.indexOf(previousSelection.edges[i]) === -1) {
- edgesChanged = true;
+ _determineDifference(firstSet, secondSet) {
+ let arrayDiff = function(firstArr, secondArr) {
+ let result = [];
+
+ for (let i = 0; i < firstArr.length; i++) {
+ let value = firstArr[i];
+ if (secondArr.indexOf(value) === -1) {
+ result.push(value);
+ }
}
- }
- return {nodesChanged, edgesChanged};
+ return result;
+ };
+
+ return {
+ nodes: arrayDiff(firstSet.nodes, secondSet.nodes),
+ edges: arrayDiff(firstSet.edges, secondSet.edges)
+ };
}
diff --git a/lib/network/modules/LayoutEngine.js b/lib/network/modules/LayoutEngine.js
index 57b9e092..1c54d34f 100644
--- a/lib/network/modules/LayoutEngine.js
+++ b/lib/network/modules/LayoutEngine.js
@@ -207,35 +207,47 @@ class LayoutEngine {
this.body.emitter.on('_resetHierarchicalLayout', () => {
this.setupHierarchicalLayout();
});
+ this.body.emitter.on('_adjustEdgesForHierarchicalLayout', () => {
+ if (this.options.hierarchical.enabled !== true) {
+ return;
+ }
+ // get the type of static smooth curve in case it is required
+ let type = this.getStaticType();
+
+ // force all edges into static smooth curves.
+ this.body.emitter.emit('_forceDisableDynamicCurves', type, false);
+ });
}
setOptions(options, allOptions) {
if (options !== undefined) {
- let prevHierarchicalState = this.options.hierarchical.enabled;
+ let hierarchical = this.options.hierarchical;
+ let prevHierarchicalState = hierarchical.enabled;
util.selectiveDeepExtend(["randomSeed", "improvedLayout"],this.options, options);
util.mergeOptions(this.options, options, 'hierarchical');
if (options.randomSeed !== undefined) {this.initialRandomSeed = options.randomSeed;}
- if (this.options.hierarchical.enabled === true) {
+ if (hierarchical.enabled === true) {
if (prevHierarchicalState === true) {
// refresh the overridden options for nodes and edges.
this.body.emitter.emit('refresh', true);
}
// make sure the level separation is the right way up
- if (this.options.hierarchical.direction === 'RL' || this.options.hierarchical.direction === 'DU') {
- if (this.options.hierarchical.levelSeparation > 0) {
- this.options.hierarchical.levelSeparation *= -1;
+ if (hierarchical.direction === 'RL' || hierarchical.direction === 'DU') {
+ if (hierarchical.levelSeparation > 0) {
+ hierarchical.levelSeparation *= -1;
}
}
else {
- if (this.options.hierarchical.levelSeparation < 0) {
- this.options.hierarchical.levelSeparation *= -1;
+ if (hierarchical.levelSeparation < 0) {
+ hierarchical.levelSeparation *= -1;
}
}
this.body.emitter.emit('_resetHierarchicalLayout');
- // because the hierarchical system needs it's own physics and smooth curve settings, we adapt the other options if needed.
+ // because the hierarchical system needs it's own physics and smooth curve settings,
+ // we adapt the other options if needed.
return this.adaptAllOptionsForHierarchicalLayout(allOptions);
}
else {
@@ -251,32 +263,32 @@ class LayoutEngine {
adaptAllOptionsForHierarchicalLayout(allOptions) {
if (this.options.hierarchical.enabled === true) {
+ let backupPhysics = this.optionsBackup.physics;
+
// set the physics
if (allOptions.physics === undefined || allOptions.physics === true) {
allOptions.physics = {
- enabled:this.optionsBackup.physics.enabled === undefined ? true : this.optionsBackup.physics.enabled,
- solver:'hierarchicalRepulsion'
+ enabled: backupPhysics.enabled === undefined ? true : backupPhysics.enabled,
+ solver :'hierarchicalRepulsion'
};
- this.optionsBackup.physics.enabled = this.optionsBackup.physics.enabled === undefined ? true : this.optionsBackup.physics.enabled;
- this.optionsBackup.physics.solver = this.optionsBackup.physics.solver || 'barnesHut';
+ backupPhysics.enabled = backupPhysics.enabled === undefined ? true : backupPhysics.enabled;
+ backupPhysics.solver = backupPhysics.solver || 'barnesHut';
}
else if (typeof allOptions.physics === 'object') {
- this.optionsBackup.physics.enabled = allOptions.physics.enabled === undefined ? true : allOptions.physics.enabled;
- this.optionsBackup.physics.solver = allOptions.physics.solver || 'barnesHut';
+ backupPhysics.enabled = allOptions.physics.enabled === undefined ? true : allOptions.physics.enabled;
+ backupPhysics.solver = allOptions.physics.solver || 'barnesHut';
allOptions.physics.solver = 'hierarchicalRepulsion';
}
else if (allOptions.physics !== false) {
- this.optionsBackup.physics.solver ='barnesHut';
+ backupPhysics.solver ='barnesHut';
allOptions.physics = {solver:'hierarchicalRepulsion'};
}
// get the type of static smooth curve in case it is required
- let type = 'horizontal';
- if (this.options.hierarchical.direction === 'RL' || this.options.hierarchical.direction === 'LR') {
- type = 'vertical';
- }
+ let type = this.getStaticType();
- // disable smooth curves if nothing is defined. If smooth curves have been turned on, turn them into static smooth curves.
+ // disable smooth curves if nothing is defined. If smooth curves have been turned on,
+ // turn them into static smooth curves.
if (allOptions.edges === undefined) {
this.optionsBackup.edges = {smooth:{enabled:true, type:'dynamic'}};
allOptions.edges = {smooth: false};
@@ -291,27 +303,34 @@ class LayoutEngine {
allOptions.edges.smooth = {enabled: allOptions.edges.smooth, type:type}
}
else {
+ let smooth = allOptions.edges.smooth;
+
// allow custom types except for dynamic
- if (allOptions.edges.smooth.type !== undefined && allOptions.edges.smooth.type !== 'dynamic') {
- type = allOptions.edges.smooth.type;
+ if (smooth.type !== undefined && smooth.type !== 'dynamic') {
+ type = smooth.type;
}
+ // TODO: this is options merging; see if the standard routines can be used here.
this.optionsBackup.edges = {
- smooth: allOptions.edges.smooth.enabled === undefined ? true : allOptions.edges.smooth.enabled,
- type: allOptions.edges.smooth.type === undefined ? 'dynamic' : allOptions.edges.smooth.type,
- roundness: allOptions.edges.smooth.roundness === undefined ? 0.5 : allOptions.edges.smooth.roundness,
- forceDirection: allOptions.edges.smooth.forceDirection === undefined ? false : allOptions.edges.smooth.forceDirection
+ smooth : smooth.enabled === undefined ? true : smooth.enabled,
+ type : smooth.type === undefined ? 'dynamic': smooth.type,
+ roundness : smooth.roundness === undefined ? 0.5 : smooth.roundness,
+ forceDirection: smooth.forceDirection === undefined ? false : smooth.forceDirection
};
+
+
+ // NOTE: Copying an object to self; this is basically setting defaults for undefined variables
allOptions.edges.smooth = {
- enabled: allOptions.edges.smooth.enabled === undefined ? true : allOptions.edges.smooth.enabled,
- type:type,
- roundness: allOptions.edges.smooth.roundness === undefined ? 0.5 : allOptions.edges.smooth.roundness,
- forceDirection: allOptions.edges.smooth.forceDirection === undefined ? false : allOptions.edges.smooth.forceDirection
+ enabled : smooth.enabled === undefined ? true : smooth.enabled,
+ type : type,
+ roundness : smooth.roundness === undefined ? 0.5 : smooth.roundness,
+ forceDirection: smooth.forceDirection === undefined ? false: smooth.forceDirection
}
}
}
- // force all edges into static smooth curves. Only applies to edges that do not use the global options for smooth.
+ // Force all edges into static smooth curves.
+ // Only applies to edges that do not use the global options for smooth.
this.body.emitter.emit('_forceDisableDynamicCurves', type);
}
@@ -347,22 +366,25 @@ class LayoutEngine {
*/
layoutNetwork() {
if (this.options.hierarchical.enabled !== true && this.options.improvedLayout === true) {
+ let indices = this.body.nodeIndices;
+
// first check if we should Kamada Kawai to layout. The threshold is if less than half of the visible
// nodes have predefined positions we use this.
let positionDefined = 0;
- for (let i = 0; i < this.body.nodeIndices.length; i++) {
- let node = this.body.nodes[this.body.nodeIndices[i]];
+ for (let i = 0; i < indices.length; i++) {
+ let node = this.body.nodes[indices[i]];
if (node.predefinedPosition === true) {
positionDefined += 1;
}
}
// if less than half of the nodes have a predefined position we continue
- if (positionDefined < 0.5 * this.body.nodeIndices.length) {
+ if (positionDefined < 0.5 * indices.length) {
let MAX_LEVELS = 10;
let level = 0;
let clusterThreshold = 150;
- //Performance enhancement, during clustering edges need only be simple straight lines. These options don't propagate outside the clustering phase.
+ // Performance enhancement, during clustering edges need only be simple straight lines.
+ // These options don't propagate outside the clustering phase.
let clusterOptions = {
clusterEdgeProperties:{
smooth: {
@@ -372,12 +394,15 @@ class LayoutEngine {
};
// if there are a lot of nodes, we cluster before we run the algorithm.
- if (this.body.nodeIndices.length > clusterThreshold) {
- let startLength = this.body.nodeIndices.length;
- while (this.body.nodeIndices.length > clusterThreshold && level <= MAX_LEVELS) {
+ // NOTE: this part fails to find clusters for large scale-free networks, which should
+ // be easily clusterable.
+ // TODO: examine why this is so
+ if (indices.length > clusterThreshold) {
+ let startLength = indices.length;
+ while (indices.length > clusterThreshold && level <= MAX_LEVELS) {
//console.time("clustering")
level += 1;
- let before = this.body.nodeIndices.length;
+ let before = indices.length;
// if there are many nodes we do a hubsize cluster
if (level % 3 === 0) {
this.body.modules.clustering.clusterBridges(clusterOptions);
@@ -385,11 +410,12 @@ class LayoutEngine {
else {
this.body.modules.clustering.clusterOutliers(clusterOptions);
}
- let after = this.body.nodeIndices.length;
+ let after = indices.length;
if (before == after && level % 3 !== 0) {
this._declusterAll();
this.body.emitter.emit("_layoutFailed");
- console.info("This network could not be positioned by this version of the improved layout algorithm. Please disable improvedLayout for better performance.");
+ console.info("This network could not be positioned by this version of the improved layout algorithm."
+ + " Please disable improvedLayout for better performance.");
return;
}
//console.timeEnd("clustering")
@@ -399,22 +425,24 @@ class LayoutEngine {
this.body.modules.kamadaKawai.setOptions({springLength: Math.max(150, 2 * startLength)})
}
if (level > MAX_LEVELS){
- console.info("The clustering didn't succeed within the amount of interations allowed, progressing with partial result.");
+ console.info("The clustering didn't succeed within the amount of interations allowed,"
+ + " progressing with partial result.");
}
// position the system for these nodes and edges
- this.body.modules.kamadaKawai.solve(this.body.nodeIndices, this.body.edgeIndices, true);
+ this.body.modules.kamadaKawai.solve(indices, this.body.edgeIndices, true);
// shift to center point
this._shiftToCenter();
// perturb the nodes a little bit to force the physics to kick in
let offset = 70;
- for (let i = 0; i < this.body.nodeIndices.length; i++) {
+ for (let i = 0; i < indices.length; i++) {
// Only perturb the nodes that aren't fixed
- if (this.body.nodes[this.body.nodeIndices[i]].predefinedPosition === false) {
- this.body.nodes[this.body.nodeIndices[i]].x += (0.5 - this.seededRandom())*offset;
- this.body.nodes[this.body.nodeIndices[i]].y += (0.5 - this.seededRandom())*offset;
+ let node = this.body.nodes[indices[i]];
+ if (node.predefinedPosition === false) {
+ node.x += (0.5 - this.seededRandom())*offset;
+ node.y += (0.5 - this.seededRandom())*offset;
}
}
@@ -435,8 +463,9 @@ class LayoutEngine {
let range = NetworkUtil.getRangeCore(this.body.nodes, this.body.nodeIndices);
let center = NetworkUtil.findCenter(range);
for (let i = 0; i < this.body.nodeIndices.length; i++) {
- this.body.nodes[this.body.nodeIndices[i]].x -= center.x;
- this.body.nodes[this.body.nodeIndices[i]].y -= center.y;
+ let node = this.body.nodes[this.body.nodeIndices[i]];
+ node.x -= center.x;
+ node.y -= center.y;
}
}
@@ -500,18 +529,20 @@ class LayoutEngine {
// if the user defined some levels but not all, alert and run without hierarchical layout
if (undefinedLevel === true && definedLevel === true) {
- throw new Error('To use the hierarchical layout, nodes require either no predefined levels or levels have to be defined for all nodes.');
+ throw new Error('To use the hierarchical layout, nodes require either no predefined levels'
+ + ' or levels have to be defined for all nodes.');
}
else {
// define levels if undefined by the users. Based on hubsize.
if (undefinedLevel === true) {
- if (this.options.hierarchical.sortMethod === 'hubsize') {
+ let sortMethod = this.options.hierarchical.sortMethod;
+ if (sortMethod === 'hubsize') {
this._determineLevelsByHubsize();
}
- else if (this.options.hierarchical.sortMethod === 'directed') {
+ else if (sortMethod === 'directed') {
this._determineLevelsDirected();
}
- else if (this.options.hierarchical.sortMethod === 'custom') {
+ else if (sortMethod === 'custom') {
this._determineLevelsCustomCallback();
}
}
@@ -686,8 +717,9 @@ class LayoutEngine {
let pos1 = this._getPositionForHierarchy(node1);
let pos2 = this._getPositionForHierarchy(node2);
let diffAbs = Math.abs(pos2 - pos1);
- //console.log("NOW CHEcKING:", node1.id, node2.id, diffAbs);
- if (diffAbs > this.options.hierarchical.nodeSpacing) {
+ let nodeSpacing = this.options.hierarchical.nodeSpacing;
+ //console.log("NOW CHECKING:", node1.id, node2.id, diffAbs);
+ if (diffAbs > nodeSpacing) {
let branchNodes1 = {};
let branchNodes2 = {};
@@ -699,12 +731,13 @@ class LayoutEngine {
let [min1,max1, minSpace1, maxSpace1] = getBranchBoundary(branchNodes1, maxLevel);
let [min2,max2, minSpace2, maxSpace2] = getBranchBoundary(branchNodes2, maxLevel);
- //console.log(node1.id, getBranchBoundary(branchNodes1, maxLevel), node2.id, getBranchBoundary(branchNodes2, maxLevel), maxLevel);
+ //console.log(node1.id, getBranchBoundary(branchNodes1, maxLevel), node2.id,
+ // getBranchBoundary(branchNodes2, maxLevel), maxLevel);
let diffBranch = Math.abs(max1 - min2);
- if (diffBranch > this.options.hierarchical.nodeSpacing) {
- let offset = max1 - min2 + this.options.hierarchical.nodeSpacing;
- if (offset < -minSpace2 + this.options.hierarchical.nodeSpacing) {
- offset = -minSpace2 + this.options.hierarchical.nodeSpacing;
+ if (diffBranch > nodeSpacing) {
+ let offset = max1 - min2 + nodeSpacing;
+ if (offset < -minSpace2 + nodeSpacing) {
+ offset = -minSpace2 + nodeSpacing;
//console.log("RESETTING OFFSET", max1 - min2 + this.options.hierarchical.nodeSpacing, -minSpace2, offset);
}
if (offset < 0) {
@@ -939,18 +972,19 @@ class LayoutEngine {
if (level !== undefined) {
let index = this.distributionIndex[node.id];
let position = this._getPositionForHierarchy(node);
+ let ordering = this.distributionOrdering[level];
let minSpace = 1e9;
let maxSpace = 1e9;
if (index !== 0) {
- let prevNode = this.distributionOrdering[level][index - 1];
+ let prevNode = ordering[index - 1];
if ((useMap === true && map[prevNode.id] === undefined) || useMap === false) {
let prevPos = this._getPositionForHierarchy(prevNode);
minSpace = position - prevPos;
}
}
- if (index != this.distributionOrdering[level].length - 1) {
- let nextNode = this.distributionOrdering[level][index + 1];
+ if (index != ordering.length - 1) {
+ let nextNode = ordering[index + 1];
if ((useMap === true && map[nextNode.id] === undefined) || useMap === false) {
let nextPos = this._getPositionForHierarchy(nextNode);
maxSpace = Math.min(maxSpace, nextPos - position);
@@ -975,24 +1009,17 @@ class LayoutEngine {
for (var i = 0; i < parents.length; i++) {
let parentId = parents[i];
let parentNode = this.body.nodes[parentId];
- if (this.hierarchical.childrenReference[parentId]) {
+ let children = this.hierarchical.childrenReference[parentId];
+
+ if (children !== undefined) {
// get the range of the children
- let minPos = 1e9;
- let maxPos = -1e9;
- let children = this.hierarchical.childrenReference[parentId];
- if (children.length > 0) {
- for (let i = 0; i < children.length; i++) {
- let childNode = this.body.nodes[children[i]];
- minPos = Math.min(minPos, this._getPositionForHierarchy(childNode));
- maxPos = Math.max(maxPos, this._getPositionForHierarchy(childNode));
- }
- }
+ let newPosition = this._getCenterPosition(children);
let position = this._getPositionForHierarchy(parentNode);
let [minSpace, maxSpace] = this._getSpaceAroundNode(parentNode);
- let newPosition = 0.5 * (minPos + maxPos);
let diff = position - newPosition;
- if ((diff < 0 && Math.abs(diff) < maxSpace - this.options.hierarchical.nodeSpacing) || (diff > 0 && Math.abs(diff) < minSpace - this.options.hierarchical.nodeSpacing)) {
+ if ((diff < 0 && Math.abs(diff) < maxSpace - this.options.hierarchical.nodeSpacing) ||
+ (diff > 0 && Math.abs(diff) < minSpace - this.options.hierarchical.nodeSpacing)) {
this._setPositionForHierarchy(parentNode, newPosition, undefined, true);
}
}
@@ -1022,9 +1049,13 @@ class LayoutEngine {
for (let i = 0; i < nodeArray.length; i++) {
let node = nodeArray[i];
if (this.positionedNodes[node.id] === undefined) {
- let pos = this.options.hierarchical.nodeSpacing * handledNodeCount;
- // we get the X or Y values we need and store them in pos and previousPos. The get and set make sure we get X or Y
- if (handledNodeCount > 0) {pos = this._getPositionForHierarchy(nodeArray[i-1]) + this.options.hierarchical.nodeSpacing;}
+ let spacing = this.options.hierarchical.nodeSpacing;
+ let pos = spacing * handledNodeCount;
+ // We get the X or Y values we need and store them in pos and previousPos.
+ // The get and set make sure we get X or Y
+ if (handledNodeCount > 0) {
+ pos = this._getPositionForHierarchy(nodeArray[i-1]) + spacing;
+ }
this._setPositionForHierarchy(node, pos, level);
this._validatePositionAndContinue(node, level, pos);
@@ -1045,15 +1076,17 @@ class LayoutEngine {
* @private
*/
_placeBranchNodes(parentId, parentLevel) {
+ let childRef = this.hierarchical.childrenReference[parentId];
+
// if this is not a parent, cancel the placing. This can happen with multiple parents to one child.
- if (this.hierarchical.childrenReference[parentId] === undefined) {
+ if (childRef === undefined) {
return;
}
// get a list of childNodes
let childNodes = [];
- for (let i = 0; i < this.hierarchical.childrenReference[parentId].length; i++) {
- childNodes.push(this.body.nodes[this.hierarchical.childrenReference[parentId][i]]);
+ for (let i = 0; i < childRef.length; i++) {
+ childNodes.push(this.body.nodes[childRef[i]]);
}
// use the positions to order the nodes.
@@ -1066,11 +1099,13 @@ class LayoutEngine {
// check if the child node is below the parent node and if it has already been positioned.
if (childNodeLevel > parentLevel && this.positionedNodes[childNode.id] === undefined) {
// get the amount of space required for this node. If parent the width is based on the amount of children.
+ let spacing = this.options.hierarchical.nodeSpacing;
let pos;
- // we get the X or Y values we need and store them in pos and previousPos. The get and set make sure we get X or Y
+ // we get the X or Y values we need and store them in pos and previousPos.
+ // The get and set make sure we get X or Y
if (i === 0) {pos = this._getPositionForHierarchy(this.body.nodes[parentId]);}
- else {pos = this._getPositionForHierarchy(childNodes[i-1]) + this.options.hierarchical.nodeSpacing;}
+ else {pos = this._getPositionForHierarchy(childNodes[i-1]) + spacing;}
this._setPositionForHierarchy(childNode, pos, childNodeLevel);
this._validatePositionAndContinue(childNode, childNodeLevel, pos);
}
@@ -1080,14 +1115,8 @@ class LayoutEngine {
}
// center the parent nodes.
- let minPos = 1e9;
- let maxPos = -1e9;
- for (let i = 0; i < childNodes.length; i++) {
- let childNodeId = childNodes[i].id;
- minPos = Math.min(minPos, this._getPositionForHierarchy(this.body.nodes[childNodeId]));
- maxPos = Math.max(maxPos, this._getPositionForHierarchy(this.body.nodes[childNodeId]));
- }
- this._setPositionForHierarchy(this.body.nodes[parentId], 0.5 * (minPos + maxPos), parentLevel);
+ let center = this._getCenterPosition(childNodes);
+ this._setPositionForHierarchy(this.body.nodes[parentId], center, parentLevel);
}
@@ -1123,8 +1152,8 @@ class LayoutEngine {
}
/**
- * Receives an array with node indices and returns an array with the actual node references. Used for sorting based on
- * node properties.
+ * Receives an array with node indices and returns an array with the actual node references.
+ * Used for sorting based on node properties.
* @param idArray
*/
_indexArrayToNodes(idArray) {
@@ -1145,13 +1174,14 @@ class LayoutEngine {
let distribution = {};
let nodeId, node;
- // we fix Y because the hierarchy is vertical, we fix X so we do not give a node an x position for a second time.
+ // we fix Y because the hierarchy is vertical,
+ // we fix X so we do not give a node an x position for a second time.
// the fix of X is removed after the x value has been set.
for (nodeId in this.body.nodes) {
if (this.body.nodes.hasOwnProperty(nodeId)) {
node = this.body.nodes[nodeId];
let level = this.hierarchical.levels[nodeId] === undefined ? 0 : this.hierarchical.levels[nodeId];
- if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') {
+ if(this._isVertical()) {
node.y = this.options.hierarchical.levelSeparation * level;
node.options.fixed.y = true;
}
@@ -1170,50 +1200,78 @@ class LayoutEngine {
/**
- * Get the hubsize from all remaining unlevelled nodes.
+ * Return the active (i.e. visible) edges for this node
*
- * @returns {number}
+ * @returns {array} Array of edge instances
* @private
*/
- _getHubSize() {
- let hubSize = 0;
- for (let nodeId in this.body.nodes) {
- if (this.body.nodes.hasOwnProperty(nodeId)) {
- let node = this.body.nodes[nodeId];
- if (this.hierarchical.levels[nodeId] === undefined) {
- hubSize = node.edges.length < hubSize ? hubSize : node.edges.length;
- }
+ _getActiveEdges(node) {
+ let result = [];
+
+ for (let j in node.edges) {
+ let edge = node.edges[j];
+ if (this.body.edgeIndices.indexOf(edge.id) !== -1) {
+ result.push(edge);
}
}
- return hubSize;
+
+ return result;
+ }
+
+
+ /**
+ * Get the hubsizes for all active nodes.
+ *
+ * @returns {number}
+ * @private
+ */
+ _getHubSizes() {
+ let hubSizes = {};
+ let nodeIds = this.body.nodeIndices;
+
+ for (let i in nodeIds) {
+ let nodeId = nodeIds[i];
+ let node = this.body.nodes[nodeId];
+ let hubSize = this._getActiveEdges(node).length;
+ hubSizes[hubSize] = true;
+ }
+
+ // Make an array of the size sorted descending
+ let result = [];
+ for (let size in hubSizes) {
+ result.push(Number(size));
+ }
+ result.sort(function(a, b) {
+ return b - a;
+ });
+
+ return result;
}
/**
* this function allocates nodes in levels based on the recursive branching from the largest hubs.
*
- * @param hubsize
* @private
*/
_determineLevelsByHubsize() {
- let hubSize = 1;
-
let levelDownstream = (nodeA, nodeB) => {
this.hierarchical.levelDownstream(nodeA, nodeB);
}
- while (hubSize > 0) {
- // determine hubs
- hubSize = this._getHubSize();
- if (hubSize === 0)
- break;
+ let hubSizes = this._getHubSizes();
- for (let nodeId in this.body.nodes) {
- if (this.body.nodes.hasOwnProperty(nodeId)) {
- let node = this.body.nodes[nodeId];
- if (node.edges.length === hubSize) {
- this._crawlNetwork(levelDownstream,nodeId);
- }
+ for (let i = 0; i < hubSizes.length; ++i ) {
+ let hubSize = hubSizes[i];
+ if (hubSize === 0) break;
+
+ let nodeIds = this.body.nodeIndices;
+ for (let j in nodeIds) {
+ let nodeId = nodeIds[j];
+ let node = this.body.nodes[nodeId];
+
+ if (hubSize === this._getActiveEdges(node).length) {
+ this._crawlNetwork(levelDownstream, nodeId);
}
}
}
@@ -1239,7 +1297,7 @@ class LayoutEngine {
let levelByDirection = (nodeA, nodeB, edge) => {
let levelA = this.hierarchical.levels[nodeA.id];
// set initial level
- if (levelA === undefined) {this.hierarchical.levels[nodeA.id] = minLevel;}
+ if (levelA === undefined) { levelA = this.hierarchical.levels[nodeA.id] = minLevel;}
let diff = customCallback(
NetworkUtil.cloneOptions(nodeA,'node'),
@@ -1247,7 +1305,7 @@ class LayoutEngine {
NetworkUtil.cloneOptions(edge,'edge')
);
- this.hierarchical.levels[nodeB.id] = this.hierarchical.levels[nodeA.id] + diff;
+ this.hierarchical.levels[nodeB.id] = levelA + diff;
};
this._crawlNetwork(levelByDirection);
@@ -1266,12 +1324,12 @@ class LayoutEngine {
let levelByDirection = (nodeA, nodeB, edge) => {
let levelA = this.hierarchical.levels[nodeA.id];
// set initial level
- if (levelA === undefined) {this.hierarchical.levels[nodeA.id] = minLevel;}
+ if (levelA === undefined) { levelA = this.hierarchical.levels[nodeA.id] = minLevel;}
if (edge.toId == nodeB.id) {
- this.hierarchical.levels[nodeB.id] = this.hierarchical.levels[nodeA.id] + 1;
+ this.hierarchical.levels[nodeB.id] = levelA + 1;
}
else {
- this.hierarchical.levels[nodeB.id] = this.hierarchical.levels[nodeA.id] - 1;
+ this.hierarchical.levels[nodeB.id] = levelA - 1;
}
};
@@ -1298,7 +1356,7 @@ class LayoutEngine {
/**
* Crawl over the entire network and use a callback on each node couple that is connected to each other.
- * @param callback | will receive nodeA nodeB and the connecting edge. A and B are unique.
+ * @param callback | will receive nodeA, nodeB and the connecting edge. A and B are distinct.
* @param startingNodeId
* @private
*/
@@ -1316,17 +1374,19 @@ class LayoutEngine {
progress[node.id] = true;
let childNode;
- for (let i = 0; i < node.edges.length; i++) {
- if (node.edges[i].connected === true) {
- if (node.edges[i].toId === node.id) {
- childNode = node.edges[i].from;
+ let edges = this._getActiveEdges(node);
+ for (let i = 0; i < edges.length; i++) {
+ let edge = edges[i];
+ if (edge.connected === true) {
+ if (edge.toId == node.id) { // '==' because id's can be string and numeric
+ childNode = edge.from;
}
else {
- childNode = node.edges[i].to;
+ childNode = edge.to;
}
- if (node.id !== childNode.id) {
- callback(node, childNode, node.edges[i]);
+ if (node.id != childNode.id) { // '!=' because id's can be string and numeric
+ callback(node, childNode, edge);
crawler(childNode, tree);
}
}
@@ -1369,15 +1429,17 @@ class LayoutEngine {
return;
}
progress[parentId] = true;
- if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') {
+ if(this._isVertical()) {
this.body.nodes[parentId].x += diff;
}
else {
this.body.nodes[parentId].y += diff;
}
- if (this.hierarchical.childrenReference[parentId] !== undefined) {
- for (let i = 0; i < this.hierarchical.childrenReference[parentId].length; i++) {
- shifter(this.hierarchical.childrenReference[parentId][i]);
+
+ let childRef = this.hierarchical.childrenReference[parentId];
+ if (childRef !== undefined) {
+ for (let i = 0; i < childRef.length; i++) {
+ shifter(childRef[i]);
}
}
};
@@ -1395,18 +1457,20 @@ class LayoutEngine {
_findCommonParent(childA,childB) {
let parents = {};
let iterateParents = (parents,child) => {
- if (this.hierarchical.parentReference[child] !== undefined) {
- for (let i = 0; i < this.hierarchical.parentReference[child].length; i++) {
- let parent = this.hierarchical.parentReference[child][i];
+ let parentRef = this.hierarchical.parentReference[child];
+ if (parentRef !== undefined) {
+ for (let i = 0; i < parentRef.length; i++) {
+ let parent = parentRef[i];
parents[parent] = true;
iterateParents(parents, parent)
}
}
};
let findParent = (parents, child) => {
- if (this.hierarchical.parentReference[child] !== undefined) {
- for (let i = 0; i < this.hierarchical.parentReference[child].length; i++) {
- let parent = this.hierarchical.parentReference[child][i];
+ let parentRef = this.hierarchical.parentReference[child];
+ if (parentRef !== undefined) {
+ for (let i = 0; i < parentRef.length; i++) {
+ let parent = parentRef[i];
if (parents[parent] !== undefined) {
return {foundParent:parent, withChild:child};
}
@@ -1445,7 +1509,7 @@ class LayoutEngine {
this.distributionOrderingPresence[level][node.id] = true;
}
- if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') {
+ if(this._isVertical()) {
node.x = position;
}
else {
@@ -1472,7 +1536,7 @@ class LayoutEngine {
* @private
*/
_getPositionForHierarchy(node) {
- if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') {
+ if(this._isVertical()) {
return node.x;
}
else {
@@ -1487,7 +1551,7 @@ class LayoutEngine {
*/
_sortNodeArray(nodeArray) {
if (nodeArray.length > 1) {
- if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') {
+ if(this._isVertical()) {
nodeArray.sort(function (a, b) {
return a.x - b.x;
})
@@ -1499,6 +1563,54 @@ class LayoutEngine {
}
}
}
+
+
+ /**
+ * Get the type of static smooth curve in case it is required.
+ *
+ * The return value is the type to use to translate dynamic curves to
+ * another type, in the case of hierarchical layout. Dynamic curves do
+ * not work for that layout type.
+ */
+ getStaticType() {
+ // Node that 'type' is the edge type, and therefore 'orthogonal' to the layout type.
+ let type = 'horizontal';
+ if (!this._isVertical()) {
+ type = 'vertical';
+ }
+
+ return type;
+ }
+
+
+ /**
+ * Determine the center position of a branch from the passed list of child nodes
+ *
+ * This takes into account the positions of all the child nodes.
+ * @param childNodes {array} Array of either child nodes or node id's
+ * @return {number}
+ * @private
+ */
+ _getCenterPosition(childNodes) {
+ let minPos = 1e9;
+ let maxPos = -1e9;
+
+ for (let i = 0; i < childNodes.length; i++) {
+ let childNode;
+ if (childNodes[i].id !== undefined) {
+ childNode = childNodes[i];
+ } else {
+ let childNodeId = childNodes[i];
+ childNode = this.body.nodes[childNodeId];
+ }
+
+ let position = this._getPositionForHierarchy(childNode);
+ minPos = Math.min(minPos, position);
+ maxPos = Math.max(maxPos, position);
+ }
+
+ return 0.5 * (minPos + maxPos);
+ }
}
export default LayoutEngine;
diff --git a/lib/network/modules/NodesHandler.js b/lib/network/modules/NodesHandler.js
index f9999327..e7e918d5 100644
--- a/lib/network/modules/NodesHandler.js
+++ b/lib/network/modules/NodesHandler.js
@@ -129,6 +129,12 @@ class NodesHandler {
x: undefined,
y: undefined
};
+
+ // Protect from idiocy
+ if (this.defaultOptions.mass <= 0) {
+ throw 'Internal error: mass in defaultOptions of NodesHandler may not be zero or negative';
+ }
+
util.extend(this.options, this.defaultOptions);
this.bindEventListeners();
@@ -408,22 +414,24 @@ class NodesHandler {
/**
* Get the Ids of nodes connected to this node.
* @param nodeId
+ * @param direction {String|undefined} values 'from' and 'to' select respectively parent and child nodes only.
+ * Any other value returns both parent and child nodes.
* @returns {Array}
*/
- getConnectedNodes(nodeId) {
+ getConnectedNodes(nodeId, direction) {
let nodeList = [];
if (this.body.nodes[nodeId] !== undefined) {
let node = this.body.nodes[nodeId];
let nodeObj = {}; // used to quickly check if node already exists
for (let i = 0; i < node.edges.length; i++) {
let edge = node.edges[i];
- if (edge.toId == node.id) { // these are double equals since ids can be numeric or string
+ if (direction !== 'from' && edge.toId == node.id) { // these are double equals since ids can be numeric or string
if (nodeObj[edge.fromId] === undefined) {
nodeList.push(edge.fromId);
nodeObj[edge.fromId] = true;
}
}
- else if (edge.fromId == node.id) { // these are double equals since ids can be numeric or string
+ else if (direction !== 'to' && edge.fromId == node.id) { // these are double equals since ids can be numeric or string
if (nodeObj[edge.toId] === undefined) {
nodeList.push(edge.toId);
nodeObj[edge.toId] = true;
diff --git a/lib/network/modules/PhysicsEngine.js b/lib/network/modules/PhysicsEngine.js
index 742e13d3..5f176a9d 100644
--- a/lib/network/modules/PhysicsEngine.js
+++ b/lib/network/modules/PhysicsEngine.js
@@ -638,20 +638,31 @@ class PhysicsEngine {
* @private
*/
_stabilizationBatch() {
+ var self = this;
+ var running = () => (self.stabilized === false && self.stabilizationIterations < self.targetIterations);
+ var sendProgress = () => {
+ self.body.emitter.emit('stabilizationProgress', {
+ iterations: self.stabilizationIterations,
+ total: self.targetIterations
+ });
+ };
+
// this is here to ensure that there is at least one start event.
if (this.startedStabilization === false) {
this.body.emitter.emit('startStabilizing');
this.startedStabilization = true;
+ sendProgress();
}
var count = 0;
- while (this.stabilized === false && count < this.options.stabilization.updateInterval && this.stabilizationIterations < this.targetIterations) {
+ while (running() && count < this.options.stabilization.updateInterval) {
this.physicsTick();
count++;
}
- if (this.stabilized === false && this.stabilizationIterations < this.targetIterations) {
- this.body.emitter.emit('stabilizationProgress', {iterations: this.stabilizationIterations, total: this.targetIterations});
+ sendProgress();
+
+ if (running()) {
setTimeout(this._stabilizationBatch.bind(this),0);
}
else {
diff --git a/lib/network/modules/components/Edge.js b/lib/network/modules/components/Edge.js
index 8f04b8c8..3e29041e 100644
--- a/lib/network/modules/components/Edge.js
+++ b/lib/network/modules/components/Edge.js
@@ -528,7 +528,11 @@ class Edge {
// set style
var node1 = this.from;
var node2 = this.to;
- var selected = (this.from.selected || this.to.selected || this.selected);
+
+ 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);
@@ -536,13 +540,13 @@ class Edge {
// if the label has to be rotated:
if (this.options.font.align !== "horizontal") {
- this.labelModule.calculateLabelSize(ctx, selected, this.hover, point.x, point.y);
+ this.labelModule.calculateLabelSize(ctx, this.selected, this.hover, point.x, point.y);
ctx.translate(point.x, this.labelModule.size.yLine);
this._rotateForLabelAlignment(ctx);
}
// draw the label
- this.labelModule.draw(ctx, point.x, point.y, selected, this.hover);
+ this.labelModule.draw(ctx, point.x, point.y, this.selected, this.hover);
ctx.restore();
}
else {
@@ -559,7 +563,7 @@ class Edge {
y = node1.y - node1.shape.height * 0.5;
}
point = this._pointOnCircle(x, y, radius, 0.125);
- this.labelModule.draw(ctx, point.x, point.y, selected, this.hover);
+ this.labelModule.draw(ctx, point.x, point.y, this.selected, this.hover);
}
}
}
diff --git a/lib/network/modules/components/Node.js b/lib/network/modules/components/Node.js
index 296815d9..f42cdb3b 100644
--- a/lib/network/modules/components/Node.js
+++ b/lib/network/modules/components/Node.js
@@ -115,6 +115,8 @@ class Node {
throw "Node must have an id";
}
+ Node.checkMass(options, this.id);
+
// set these options locally
// clear x and y positions
if (options.x !== undefined) {
@@ -210,6 +212,8 @@ class Node {
];
util.selectiveNotDeepExtend(fields, parentOptions, newOptions, allowDeletion);
+ Node.checkMass(newOptions);
+
// merge the shadow options into the parent.
util.mergeOptions(parentOptions, newOptions, 'shadow', allowDeletion, globalOptions);
@@ -538,6 +542,24 @@ class Node {
this.shape.boundingBox.bottom > obj.top
);
}
+
+
+ /**
+ * Check valid values for mass
+ *
+ * The mass may not be negative or zero. If it is, reset to 1
+ */
+ static checkMass(options, id) {
+ if (options.mass !== undefined && options.mass <= 0) {
+ let strId = '';
+ if (id !== undefined) {
+ strId = ' in node id: ' + id;
+ }
+ console.log('%cNegative or zero mass disallowed' + strId +
+ ', setting mass to 1.' , printStyle);
+ options.mass = 1;
+ }
+ }
}
export default Node;
diff --git a/lib/network/modules/components/edges/BezierEdgeDynamic.js b/lib/network/modules/components/edges/BezierEdgeDynamic.js
index a57081c4..203478ca 100644
--- a/lib/network/modules/components/edges/BezierEdgeDynamic.js
+++ b/lib/network/modules/components/edges/BezierEdgeDynamic.js
@@ -103,20 +103,7 @@ class BezierEdgeDynamic extends BezierEdgeBase {
* @private
*/
_line(ctx, values, viaNode) {
- // draw a straight line
- ctx.beginPath();
- ctx.moveTo(this.fromPoint.x, this.fromPoint.y);
- // fallback to normal straight edges
- if (viaNode.x === undefined) {
- ctx.lineTo(this.toPoint.x, this.toPoint.y);
- }
- else {
- ctx.quadraticCurveTo(viaNode.x, viaNode.y, this.toPoint.x, this.toPoint.y);
- }
- // draw shadow if enabled
- this.enableShadow(ctx, values);
- ctx.stroke();
- this.disableShadow(ctx, values);
+ this._bezierCurve(ctx, values, viaNode);
}
getViaNode() {
diff --git a/lib/network/modules/components/edges/BezierEdgeStatic.js b/lib/network/modules/components/edges/BezierEdgeStatic.js
index fbfb788c..d2bfe629 100644
--- a/lib/network/modules/components/edges/BezierEdgeStatic.js
+++ b/lib/network/modules/components/edges/BezierEdgeStatic.js
@@ -11,21 +11,7 @@ class BezierEdgeStatic extends BezierEdgeBase {
* @private
*/
_line(ctx, values, viaNode) {
- // draw a straight line
- ctx.beginPath();
- ctx.moveTo(this.fromPoint.x, this.fromPoint.y);
-
- // fallback to normal straight edges
- if (viaNode.x === undefined) {
- ctx.lineTo(this.toPoint.x, this.toPoint.y);
- }
- else {
- ctx.quadraticCurveTo(viaNode.x, viaNode.y, this.toPoint.x, this.toPoint.y);
- }
- // draw shadow if enabled
- this.enableShadow(ctx, values);
- ctx.stroke();
- this.disableShadow(ctx, values);
+ this._bezierCurve(ctx, values, viaNode);
}
getViaNode() {
@@ -39,6 +25,7 @@ class BezierEdgeStatic extends BezierEdgeBase {
* @private
*/
_getViaCoordinates() {
+ // Assumption: x/y coordinates in from/to always defined
let xVia = undefined;
let yVia = undefined;
let factor = this.options.smooth.roundness;
@@ -46,94 +33,55 @@ class BezierEdgeStatic extends BezierEdgeBase {
let dx = Math.abs(this.from.x - this.to.x);
let dy = Math.abs(this.from.y - this.to.y);
if (type === 'discrete' || type === 'diagonalCross') {
- if (Math.abs(this.from.x - this.to.x) <= Math.abs(this.from.y - this.to.y)) {
- if (this.from.y >= this.to.y) {
- if (this.from.x <= this.to.x) {
- xVia = this.from.x + factor * dy;
- yVia = this.from.y - factor * dy;
- }
- else if (this.from.x > this.to.x) {
- xVia = this.from.x - factor * dy;
- yVia = this.from.y - factor * dy;
- }
- }
- else if (this.from.y < this.to.y) {
- if (this.from.x <= this.to.x) {
- xVia = this.from.x + factor * dy;
- yVia = this.from.y + factor * dy;
- }
- else if (this.from.x > this.to.x) {
- xVia = this.from.x - factor * dy;
- yVia = this.from.y + factor * dy;
- }
- }
- if (type === "discrete") {
- xVia = dx < factor * dy ? this.from.x : xVia;
- }
+ let stepX;
+ let stepY;
+
+ if (dx <= dy) {
+ stepX = stepY = factor * dy;
+ } else {
+ stepX = stepY = factor * dx;
}
- else if (Math.abs(this.from.x - this.to.x) > Math.abs(this.from.y - this.to.y)) {
- if (this.from.y >= this.to.y) {
- if (this.from.x <= this.to.x) {
- xVia = this.from.x + factor * dx;
- yVia = this.from.y - factor * dx;
- }
- else if (this.from.x > this.to.x) {
- xVia = this.from.x - factor * dx;
- yVia = this.from.y - factor * dx;
- }
- }
- else if (this.from.y < this.to.y) {
- if (this.from.x <= this.to.x) {
- xVia = this.from.x + factor * dx;
- yVia = this.from.y + factor * dx;
- }
- else if (this.from.x > this.to.x) {
- xVia = this.from.x - factor * dx;
- yVia = this.from.y + factor * dx;
- }
- }
- if (type === "discrete") {
+
+ if (this.from.x > this.to.x) stepX = -stepX;
+ if (this.from.y >= this.to.y) stepY = -stepY;
+
+ xVia = this.from.x + stepX;
+ yVia = this.from.y + stepY;
+
+ if (type === "discrete") {
+ if (dx <= dy) {
+ xVia = dx < factor * dy ? this.from.x : xVia;
+ } else {
yVia = dy < factor * dx ? this.from.y : yVia;
}
}
}
else if (type === "straightCross") {
- if (Math.abs(this.from.x - this.to.x) <= Math.abs(this.from.y - this.to.y)) { // up - down
- xVia = this.from.x;
- if (this.from.y < this.to.y) {
- yVia = this.to.y - (1 - factor) * dy;
- }
- else {
- yVia = this.to.y + (1 - factor) * dy;
- }
+ let stepX = (1 - factor) * dx;
+ let stepY = (1 - factor) * dy;
+
+ if (dx <= dy) { // up - down
+ stepX = 0;
+ if (this.from.y < this.to.y) stepY = -stepY;
}
- else if (Math.abs(this.from.x - this.to.x) > Math.abs(this.from.y - this.to.y)) { // left - right
- if (this.from.x < this.to.x) {
- xVia = this.to.x - (1 - factor) * dx;
- }
- else {
- xVia = this.to.x + (1 - factor) * dx;
- }
- yVia = this.from.y;
+ else { // left - right
+ if (this.from.x < this.to.x) stepX = -stepX;
+ stepY = 0;
}
+ xVia = this.to.x + stepX;
+ yVia = this.to.y + stepY;
}
else if (type === 'horizontal') {
- if (this.from.x < this.to.x) {
- xVia = this.to.x - (1 - factor) * dx;
- }
- else {
- xVia = this.to.x + (1 - factor) * dx;
- }
+ let stepX = (1 - factor) * dx;
+ if (this.from.x < this.to.x) stepX = -stepX;
+ xVia = this.to.x + stepX;
yVia = this.from.y;
}
else if (type === 'vertical') {
+ let stepY = (1 - factor) * dy;
+ if (this.from.y < this.to.y) stepY = -stepY;
xVia = this.from.x;
- if (this.from.y < this.to.y) {
- yVia = this.to.y - (1 - factor) * dy;
- }
- else {
- yVia = this.to.y + (1 - factor) * dy;
- }
+ yVia = this.to.y + stepY;
}
else if (type === 'curvedCW') {
dx = this.to.x - this.from.x;
@@ -160,56 +108,34 @@ class BezierEdgeStatic extends BezierEdgeBase {
yVia = this.from.y + (factor * 0.5 + 0.5) * radius * Math.cos(myAngle);
}
else { // continuous
- if (Math.abs(this.from.x - this.to.x) <= Math.abs(this.from.y - this.to.y)) {
- if (this.from.y >= this.to.y) {
- if (this.from.x <= this.to.x) {
- xVia = this.from.x + factor * dy;
- yVia = this.from.y - factor * dy;
- xVia = this.to.x < xVia ? this.to.x : xVia;
- }
- else if (this.from.x > this.to.x) {
- xVia = this.from.x - factor * dy;
- yVia = this.from.y - factor * dy;
- xVia = this.to.x > xVia ? this.to.x : xVia;
- }
+ let stepX;
+ let stepY;
+
+ if (dx <= dy) {
+ stepX = stepY = factor * dy;
+ } else {
+ stepX = stepY = factor * dx;
+ }
+
+ if (this.from.x > this.to.x) stepX = -stepX;
+ if (this.from.y >= this.to.y) stepY = -stepY;
+
+ xVia = this.from.x + stepX;
+ yVia = this.from.y + stepY;
+
+ if (dx <= dy) {
+ if (this.from.x <= this.to.x) {
+ xVia = this.to.x < xVia ? this.to.x : xVia;
}
- else if (this.from.y < this.to.y) {
- if (this.from.x <= this.to.x) {
- xVia = this.from.x + factor * dy;
- yVia = this.from.y + factor * dy;
- xVia = this.to.x < xVia ? this.to.x : xVia;
- }
- else if (this.from.x > this.to.x) {
- xVia = this.from.x - factor * dy;
- yVia = this.from.y + factor * dy;
- xVia = this.to.x > xVia ? this.to.x : xVia;
- }
+ else {
+ xVia = this.to.x > xVia ? this.to.x : xVia;
}
}
- else if (Math.abs(this.from.x - this.to.x) > Math.abs(this.from.y - this.to.y)) {
+ else {
if (this.from.y >= this.to.y) {
- if (this.from.x <= this.to.x) {
- xVia = this.from.x + factor * dx;
- yVia = this.from.y - factor * dx;
- yVia = this.to.y > yVia ? this.to.y : yVia;
- }
- else if (this.from.x > this.to.x) {
- xVia = this.from.x - factor * dx;
- yVia = this.from.y - factor * dx;
- yVia = this.to.y > yVia ? this.to.y : yVia;
- }
- }
- else if (this.from.y < this.to.y) {
- if (this.from.x <= this.to.x) {
- xVia = this.from.x + factor * dx;
- yVia = this.from.y + factor * dx;
- yVia = this.to.y < yVia ? this.to.y : yVia;
- }
- else if (this.from.x > this.to.x) {
- xVia = this.from.x - factor * dx;
- yVia = this.from.y + factor * dx;
- yVia = this.to.y < yVia ? this.to.y : yVia;
- }
+ yVia = this.to.y > yVia ? this.to.y : yVia;
+ } else {
+ yVia = this.to.y < yVia ? this.to.y : yVia;
}
}
}
@@ -241,4 +167,4 @@ class BezierEdgeStatic extends BezierEdgeBase {
}
-export default BezierEdgeStatic;
\ No newline at end of file
+export default BezierEdgeStatic;
diff --git a/lib/network/modules/components/edges/CubicBezierEdge.js b/lib/network/modules/components/edges/CubicBezierEdge.js
index 4c9579fb..fcfde588 100644
--- a/lib/network/modules/components/edges/CubicBezierEdge.js
+++ b/lib/network/modules/components/edges/CubicBezierEdge.js
@@ -14,22 +14,7 @@ class CubicBezierEdge extends CubicBezierEdgeBase {
// get the coordinates of the support points.
let via1 = viaNodes[0];
let via2 = viaNodes[1];
-
- // start drawing the line.
- ctx.beginPath();
- ctx.moveTo(this.fromPoint.x, this.fromPoint.y);
-
- // fallback to normal straight edges
- if (viaNodes === undefined || via1.x === undefined) {
- ctx.lineTo(this.toPoint.x, this.toPoint.y);
- }
- else {
- ctx.bezierCurveTo(via1.x, via1.y, via2.x, via2.y, this.toPoint.x, this.toPoint.y);
- }
- // draw shadow if enabled
- this.enableShadow(ctx, values);
- ctx.stroke();
- this.disableShadow(ctx, values);
+ this._bezierCurve(ctx, values, via1, via2);
}
_getViaCoordinates() {
@@ -90,4 +75,4 @@ class CubicBezierEdge extends CubicBezierEdgeBase {
}
-export default CubicBezierEdge;
\ No newline at end of file
+export default CubicBezierEdge;
diff --git a/lib/network/modules/components/edges/util/BezierEdgeBase.js b/lib/network/modules/components/edges/util/BezierEdgeBase.js
index 48b663bf..6bc82c88 100644
--- a/lib/network/modules/components/edges/util/BezierEdgeBase.js
+++ b/lib/network/modules/components/edges/util/BezierEdgeBase.js
@@ -101,6 +101,47 @@ class BezierEdgeBase extends EdgeBase {
return minDistance;
}
+
+
+ /**
+ * Draw a bezier curve between two nodes
+ *
+ * The method accepts zero, one or two control points.
+ * Passing zero control points just draws a straight line
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {Object} values | options for shadow drawing
+ * @param {Object|undefined} viaNode1 | first control point for curve drawing
+ * @param {Object|undefined} viaNode2 | second control point for curve drawing
+ *
+ * @protected
+ */
+ _bezierCurve(ctx, values, viaNode1, viaNode2) {
+ var hasNode1 = (viaNode1 !== undefined && viaNode1.x !== undefined);
+ var hasNode2 = (viaNode2 !== undefined && viaNode2.x !== undefined);
+
+ ctx.beginPath();
+ ctx.moveTo(this.fromPoint.x, this.fromPoint.y);
+
+ if (hasNode1 && hasNode2) {
+ ctx.bezierCurveTo(viaNode1.x, viaNode1.y, viaNode2.x, viaNode2.y, this.toPoint.x, this.toPoint.y);
+ } else if (hasNode1) {
+ ctx.quadraticCurveTo(viaNode1.x, viaNode1.y, this.toPoint.x, this.toPoint.y);
+ } else {
+ // fallback to normal straight edge
+ ctx.lineTo(this.toPoint.x, this.toPoint.y);
+ }
+
+ // draw shadow if enabled
+ this.enableShadow(ctx, values);
+ ctx.stroke();
+ this.disableShadow(ctx, values);
+ }
+
+
+ getViaNode() {
+ return this._getViaCoordinates();
+ }
}
-export default BezierEdgeBase;
\ No newline at end of file
+export default BezierEdgeBase;
diff --git a/lib/network/modules/components/shared/Label.js b/lib/network/modules/components/shared/Label.js
index ea787b89..09954ea8 100644
--- a/lib/network/modules/components/shared/Label.js
+++ b/lib/network/modules/components/shared/Label.js
@@ -778,18 +778,14 @@ class Label {
strokeWidth: this.fontOptions.strokeWidth,
strokeColor: this.fontOptions.strokeColor
};
- if (mod === "normal") {
- if (selected || hover) {
- if ((this.fontOptions.chooser === true) && (this.elementOptions.labelHighlightBold)) {
+ 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(ctx, values, this.elementOptions.id, selected, hover);
+ } else {
+ if (typeof this.fontOptions.chooser === 'function') {
+ this.fontOptions.chooser(values, this.elementOptions.id, selected, hover);
}
}
- } else {
- if ((selected || hover) && (typeof this.fontOptions.chooser === 'function')) {
- this.fontOptions.chooser(ctx, values, this.elementOptions.id, selected, hover);
- }
}
ctx.font = (values.mod + " " + values.size + "px " + values.face).replace(/"/g, "");
values.font = ctx.font;
diff --git a/lib/timeline/Range.js b/lib/timeline/Range.js
index 0ae6dec0..38ddd317 100644
--- a/lib/timeline/Range.js
+++ b/lib/timeline/Range.js
@@ -16,7 +16,8 @@ function Range(body, options) {
var now = moment().hours(0).minutes(0).seconds(0).milliseconds(0);
var start = now.clone().add(-3, 'days').valueOf();
var end = now.clone().add(3, 'days').valueOf();
-
+ this.millisecondsPerPixelCache = undefined;
+
if(options === undefined) {
this.start = start;
this.end = end;
@@ -200,6 +201,7 @@ Range.prototype.setRange = function(start, end, options, callback) {
var finalStart = start != undefined ? util.convert(start, 'Date').valueOf() : null;
var finalEnd = end != undefined ? util.convert(end, 'Date').valueOf() : null;
this._cancelAnimation();
+ this.millisecondsPerPixelCache = undefined;
if (options.animation) { // true or an Object
var initStart = this.start;
@@ -280,7 +282,10 @@ Range.prototype.setRange = function(start, end, options, callback) {
* Get the number of milliseconds per pixel.
*/
Range.prototype.getMillisecondsPerPixel = function() {
- return (this.end - this.start) / this.body.dom.center.clientWidth;
+ if (this.millisecondsPerPixelCache === undefined) {
+ this.millisecondsPerPixelCache = (this.end - this.start) / this.body.dom.center.clientWidth;
+ }
+ return this.millisecondsPerPixelCache;
}
/**
diff --git a/lib/timeline/TimeStep.js b/lib/timeline/TimeStep.js
index 530e1726..1e566201 100644
--- a/lib/timeline/TimeStep.js
+++ b/lib/timeline/TimeStep.js
@@ -148,6 +148,10 @@ TimeStep.prototype.start = function() {
*/
TimeStep.prototype.roundToMinor = function() {
// round to floor
+ // to prevent year & month scales rounding down to the first day of week we perform this separately
+ if (this.scale == 'week') {
+ this.current.weekday(0);
+ }
// IMPORTANT: we have no breaks in this switch! (this is no bug)
// noinspection FallThroughInSwitchStatementJS
switch (this.scale) {
@@ -155,7 +159,7 @@ TimeStep.prototype.roundToMinor = function() {
this.current.year(this.step * Math.floor(this.current.year() / this.step));
this.current.month(0);
case 'month': this.current.date(1);
- case 'week': this.current.weekday(0);
+ case 'week': // intentional fall through
case 'day': // intentional fall through
case 'weekday': this.current.hours(0);
case 'hour': this.current.minutes(0);
diff --git a/lib/timeline/component/Group.js b/lib/timeline/component/Group.js
index 095262ea..2a6d550b 100644
--- a/lib/timeline/component/Group.js
+++ b/lib/timeline/component/Group.js
@@ -534,6 +534,7 @@ Group.prototype.resetSubgroups = function() {
for (var subgroup in this.subgroups) {
if (this.subgroups.hasOwnProperty(subgroup)) {
this.subgroups[subgroup].visible = false;
+ this.subgroups[subgroup].height = 0;
}
}
};
diff --git a/lib/timeline/component/ItemSet.js b/lib/timeline/component/ItemSet.js
index 362d4320..0aa845b9 100644
--- a/lib/timeline/component/ItemSet.js
+++ b/lib/timeline/component/ItemSet.js
@@ -44,7 +44,10 @@ function ItemSet(body, options) {
selectable: true,
multiselect: false,
- itemsAlwaysDraggable: false,
+ itemsAlwaysDraggable: {
+ item: false,
+ range: false,
+ },
editable: {
updateTime: false,
@@ -336,12 +339,26 @@ ItemSet.prototype.setOptions = function(options) {
if (options) {
// copy all options that we know
var fields = [
- 'type', 'rtl', 'align', 'order', 'stack', 'stackSubgroups', 'selectable', 'multiselect', 'itemsAlwaysDraggable',
+ 'type', 'rtl', 'align', 'order', 'stack', 'stackSubgroups', 'selectable', 'multiselect',
'multiselectPerGroup', 'groupOrder', 'dataAttributes', 'template', 'groupTemplate', 'visibleFrameTemplate',
'hide', 'snap', 'groupOrderSwap', 'showTooltips', 'tooltip', 'tooltipOnItemUpdateTime'
];
util.selectiveExtend(fields, this.options, options);
+ if ('itemsAlwaysDraggable' in options) {
+ if (typeof options.itemsAlwaysDraggable === 'boolean') {
+ this.options.itemsAlwaysDraggable.item = options.itemsAlwaysDraggable;
+ this.options.itemsAlwaysDraggable.range = false;
+ }
+ else if (typeof options.itemsAlwaysDraggable === 'object') {
+ util.selectiveExtend(['item', 'range'], this.options.itemsAlwaysDraggable, options.itemsAlwaysDraggable);
+ // only allow range always draggable when item is always draggable as well
+ if (! this.options.itemsAlwaysDraggable.item) {
+ this.options.itemsAlwaysDraggable.range = false;
+ }
+ }
+ }
+
if ('orientation' in options) {
if (typeof options.orientation === 'string') {
this.options.orientation.item = options.orientation === 'top' ? 'top' : 'bottom';
@@ -1285,7 +1302,7 @@ ItemSet.prototype._onDragStart = function (event) {
var me = this;
var props;
- if (item && (item.selected || this.options.itemsAlwaysDraggable)) {
+ if (item && (item.selected || this.options.itemsAlwaysDraggable.item)) {
if (this.options.editable.overrideItems &&
!this.options.editable.updateTime &&
@@ -1327,7 +1344,7 @@ ItemSet.prototype._onDragStart = function (event) {
else {
var baseGroupIndex = this._getGroupIndex(item.data.group);
- var itemsToDrag = (this.options.itemsAlwaysDraggable && !item.selected) ? [item.id] : this.getSelection();
+ var itemsToDrag = (this.options.itemsAlwaysDraggable.item && !item.selected) ? [item.id] : this.getSelection();
this.touchParams.itemProps = itemsToDrag.map(function (id) {
var item = me.items[id];
diff --git a/lib/timeline/component/item/Item.js b/lib/timeline/component/item/Item.js
index 9bfa662d..cb16b766 100644
--- a/lib/timeline/component/item/Item.js
+++ b/lib/timeline/component/item/Item.js
@@ -169,8 +169,13 @@ Item.prototype._repaintDragCenter = function () {
});
if (this.dom.box) {
- this.dom.box.appendChild(dragCenter);
- }
+ if (this.dom.dragLeft) {
+ this.dom.box.insertBefore(dragCenter, this.dom.dragLeft);
+ }
+ else {
+ this.dom.box.appendChild(dragCenter);
+ }
+ }
else if (this.dom.point) {
this.dom.point.appendChild(dragCenter);
}
diff --git a/lib/timeline/component/item/RangeItem.js b/lib/timeline/component/item/RangeItem.js
index 7deaf74c..27dedd54 100644
--- a/lib/timeline/component/item/RangeItem.js
+++ b/lib/timeline/component/item/RangeItem.js
@@ -171,6 +171,7 @@ RangeItem.prototype.repositionX = function(limitSize) {
var parentWidth = this.parent.width;
var start = this.conversion.toScreen(this.data.start);
var end = this.conversion.toScreen(this.data.end);
+ var align = this.data.align === undefined ? this.options.align : this.data.align;
var contentStartPosition;
var contentWidth;
@@ -217,7 +218,7 @@ RangeItem.prototype.repositionX = function(limitSize) {
}
this.dom.box.style.width = boxWidth + 'px';
- switch (this.options.align) {
+ switch (align) {
case 'left':
if (this.options.rtl) {
this.dom.content.style.right = '0';
@@ -291,7 +292,7 @@ RangeItem.prototype.repositionY = function() {
* @protected
*/
RangeItem.prototype._repaintDragLeft = function () {
- if (this.selected && this.options.editable.updateTime && !this.dom.dragLeft) {
+ if ((this.selected || this.options.itemsAlwaysDraggable.range) && this.options.editable.updateTime && !this.dom.dragLeft) {
// create and show drag area
var dragLeft = document.createElement('div');
dragLeft.className = 'vis-drag-left';
@@ -300,7 +301,7 @@ RangeItem.prototype._repaintDragLeft = function () {
this.dom.box.appendChild(dragLeft);
this.dom.dragLeft = dragLeft;
}
- else if (!this.selected && this.dom.dragLeft) {
+ else if (!this.selected && !this.options.itemsAlwaysDraggable.range && this.dom.dragLeft) {
// delete drag area
if (this.dom.dragLeft.parentNode) {
this.dom.dragLeft.parentNode.removeChild(this.dom.dragLeft);
@@ -314,7 +315,7 @@ RangeItem.prototype._repaintDragLeft = function () {
* @protected
*/
RangeItem.prototype._repaintDragRight = function () {
- if (this.selected && this.options.editable.updateTime && !this.dom.dragRight) {
+ if ((this.selected || this.options.itemsAlwaysDraggable.range) && this.options.editable.updateTime && !this.dom.dragRight) {
// create and show drag area
var dragRight = document.createElement('div');
dragRight.className = 'vis-drag-right';
@@ -323,7 +324,7 @@ RangeItem.prototype._repaintDragRight = function () {
this.dom.box.appendChild(dragRight);
this.dom.dragRight = dragRight;
}
- else if (!this.selected && this.dom.dragRight) {
+ else if (!this.selected && !this.options.itemsAlwaysDraggable.range && this.dom.dragRight) {
// delete drag area
if (this.dom.dragRight.parentNode) {
this.dom.dragRight.parentNode.removeChild(this.dom.dragRight);
diff --git a/lib/timeline/optionsTimeline.js b/lib/timeline/optionsTimeline.js
index 56df9c63..254cfa8c 100644
--- a/lib/timeline/optionsTimeline.js
+++ b/lib/timeline/optionsTimeline.js
@@ -89,7 +89,11 @@ let allOptions = {
repeat: {string},
__type__: {object, array}
},
- itemsAlwaysDraggable: { 'boolean': bool},
+ itemsAlwaysDraggable: {
+ item: { 'boolean': bool, 'undefined': 'undefined'},
+ range: { 'boolean': bool, 'undefined': 'undefined'},
+ __type__: { 'boolean': bool, object}
+ },
locale:{string},
locales:{
__any__: {any},
diff --git a/lib/util.js b/lib/util.js
index 24e2641a..426fc854 100644
--- a/lib/util.js
+++ b/lib/util.js
@@ -621,12 +621,13 @@ exports.getAbsoluteTop = function (elem) {
* @param {Element} elem
* @param {String} className
*/
-exports.addClassName = function (elem, className) {
+exports.addClassName = function (elem, classNames) {
var classes = elem.className.split(' ');
- if (classes.indexOf(className) == -1) {
- classes.push(className); // add the class to the array
- elem.className = classes.join(' ');
- }
+ var newClasses = classNames.split(' ');
+ classes = classes.concat(newClasses.filter(function(className) {
+ return classes.indexOf(className) < 0;
+ }));
+ elem.className = classes.join(' ');
};
/**
@@ -634,13 +635,13 @@ exports.addClassName = function (elem, className) {
* @param {Element} elem
* @param {String} className
*/
-exports.removeClassName = function (elem, className) {
+exports.removeClassName = function (elem, classNames) {
var classes = elem.className.split(' ');
- var index = classes.indexOf(className);
- if (index != -1) {
- classes.splice(index, 1); // remove the class from the array
- elem.className = classes.join(' ');
- }
+ var oldClasses = classNames.split(' ');
+ classes = classes.filter(function(className) {
+ return oldClasses.indexOf(className) < 0;
+ });
+ elem.className = classes.join(' ');
};
/**
diff --git a/package.json b/package.json
index a9ae4053..e1d285ee 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "vis",
- "version": "4.19.1-SNAPSHOT",
+ "version": "4.20.1-SNAPSHOT",
"description": "A dynamic, browser-based visualization library.",
"homepage": "http://visjs.org/",
"license": "(Apache-2.0 OR MIT)",
diff --git a/test/TimeStep.test.js b/test/TimeStep.test.js
index 4ab3b2ca..de5210bb 100644
--- a/test/TimeStep.test.js
+++ b/test/TimeStep.test.js
@@ -41,4 +41,50 @@ describe('TimeStep', function () {
assert.equal(timestep.scale, "second", "should have right scale");
assert.equal(timestep.step, 10, "should have right step size");
});
+
+ it('should perform the step with a specified scale (1 year)', function () {
+ var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5));
+ timestep.setScale({ scale: 'year', step: 1 });
+ timestep.start();
+ assert.equal(timestep.getCurrent().unix(), moment("2017-01-01T00:00:00.000").unix(), "should have the right initial value");
+ timestep.next();
+ assert.equal(timestep.getCurrent().unix(), moment("2018-01-01T00:00:00.000").unix(), "should have the right value after a step");
+ });
+
+ it('should perform the step with a specified scale (1 month)', function () {
+ var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5));
+ timestep.setScale({ scale: 'month', step: 1 });
+ timestep.start();
+ assert.equal(timestep.getCurrent().unix(), moment("2017-04-01T00:00:00.000").unix(), "should have the right initial value");
+ timestep.next();
+ assert.equal(timestep.getCurrent().unix(), moment("2017-05-01T00:00:00.000").unix(), "should have the right value after a step");
+ });
+
+ it('should perform the step with a specified scale (1 week)', function () {
+ var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5));
+ timestep.setScale({ scale: 'week', step: 1 });
+ timestep.start();
+ assert.equal(timestep.getCurrent().unix(), moment("2017-04-02T00:00:00.000").unix(), "should have the right initial value");
+ timestep.next();
+ assert.equal(timestep.getCurrent().unix(), moment("2017-04-09T00:00:00.000").unix(), "should have the right value after a step");
+ });
+
+ it('should perform the step with a specified scale (1 day)', function () {
+ var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5));
+ timestep.setScale({ scale: 'day', step: 1 });
+ timestep.start();
+ assert.equal(timestep.getCurrent().unix(), moment("2017-04-03T00:00:00.000").unix(), "should have the right initial value");
+ timestep.next();
+ assert.equal(timestep.getCurrent().unix(), moment("2017-04-04T00:00:00.000").unix(), "should have the right value after a step");
+ });
+
+ it('should perform the step with a specified scale (1 hour)', function () {
+ var timestep = new TimeStep(new Date(2017, 3, 3), new Date(2017, 3, 5));
+ timestep.setScale({ scale: 'hour', step: 1 });
+ timestep.start();
+ assert.equal(timestep.getCurrent().unix(), moment("2017-04-03T00:00:00.000").unix(), "should have the right initial value");
+ timestep.next();
+ assert.equal(timestep.getCurrent().unix(), moment("2017-04-03T01:00:00.000").unix(), "should have the right value after a step");
+ });
+
});
\ No newline at end of file
diff --git a/test/dotparser.test.js b/test/dotparser.test.js
index da951f26..c9c6412b 100644
--- a/test/dotparser.test.js
+++ b/test/dotparser.test.js
@@ -183,4 +183,78 @@ describe('dotparser', function () {
});
});
+
+ /**
+ * DOT-format examples taken from #3015
+ */
+ it('properly handles newline escape sequences in strings', function (done) {
+ var data = 'dinetwork {1 [label="new\\nline"];}';
+
+ data = String(data);
+
+ var graph = dot.parseDOT(data);
+
+ assert.deepEqual(graph, {
+ "id": "dinetwork",
+ "nodes": [
+ {
+ "id": 1,
+ "attr": {
+ "label": "new\nline", // And not "new\\nline"
+ }
+ }
+ ]
+ });
+
+
+ // Note the double backslashes
+ var data2 = 'digraph {' + "\n" +
+' 3 [color="#0d2b7c", label="query:1230:add_q\\n0.005283\\n6.83%\\n(0.0001)\\n(0.13%)\\n17×"];' + "\n" +
+' 3 -> 7 [color="#0d2a7b", fontcolor="#0d2a7b", label="0.005128\\n6.63%\\n17×"];' + "\n" +
+' 5 [color="#0d1976", label="urlresolvers:537:reverse\\n0.00219\\n2.83%\\n(0.000193)\\n(0.25%)\\n29×"];' + "\n" +
+"}"
+
+ data2 = String(data2);
+
+ var graph2 = dot.parseDOT(data2);
+ //console.log(JSON.stringify(graph, null, 2));
+
+ assert.deepEqual(graph2, {
+ "type": "digraph",
+ "nodes": [
+ {
+ "id": 3,
+ "attr": {
+ "color": "#0d2b7c",
+ "label": "query:1230:add_q\n0.005283\n6.83%\n(0.0001)\n(0.13%)\n17×"
+ }
+ },
+ {
+ "id": 7
+ },
+ {
+ "id": 5,
+ "attr": {
+ "color": "#0d1976",
+ "label": "urlresolvers:537:reverse\n0.00219\n2.83%\n(0.000193)\n(0.25%)\n29×"
+ }
+ }
+ ],
+ "edges": [
+ {
+ "from": 3,
+ "to": 7,
+ "type": "->",
+ "attr": {
+ "color": "#0d2a7b",
+ "fontcolor": "#0d2a7b",
+ "label": "0.005128\n6.63%\n17×"
+ }
+ }
+ ]
+ });
+
+ done();
+ });
+
});