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.

- +
@@ -311,69 +311,83 @@ var options = { - - + + - + - - + + - - + + - + + - - + + + - + + + + + + + + + + + + + + + + + - - + + - + - - + + - - + + - - + + @@ -516,37 +530,95 @@ var options = { - - + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/network/index.html b/docs/network/index.html index cc86669f..9bdf520a 100644 --- a/docs/network/index.html +++ b/docs/network/index.html @@ -611,13 +611,18 @@ var locales = { - + 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. + + + + + + @@ -729,12 +736,24 @@ function (option, path) { - - + + + + + + + + + + + + + + diff --git a/examples/timeline/editing/itemsAlwaysDraggable.html b/examples/timeline/editing/itemsAlwaysDraggable.html new file mode 100644 index 00000000..24ab53c2 --- /dev/null +++ b/examples/timeline/editing/itemsAlwaysDraggable.html @@ -0,0 +1,38 @@ + + + Timeline | itemsAlwaysDraggable Option + + + + + +

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(); + }); + });
Name TypeThe 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.stroke
backgroundColor.strokeWidth
cameraPosition
cameraPositionObject 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.
dataColor
dataColor string or object{fill: '#7DC1FF', stroke: '#3267D2', strokeWidth: 1}Object 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.stroke
dataColor.strokeWidth
tooltipStyle
tooltipStyleObject Object -
-{ 
-  content: {
-    padding: '10px',
-    border: '1px solid #4d4d4d',
-    color: '#1a1a1a',
-    background: 'rgba(255,255,255,0.7)',
-    borderRadius: '2px',
-    boxShadow: '5px 5px 10px rgba(128,128,128,0.5)'
-  },
-  line: {
-    height: '40px',
-    width: '0',
-    borderLeft: '1px solid #4d4d4d'
-  },
-  dot: {
-    height: '0',
-    width: '0',
-    border: '5px solid #4d4d4d',
-    borderRadius: '5px'
-  }
-}
Tooltip style properties. Provided properties will be merged with the default object.
valueMax
findNode( - String nodeId) + String/Number nodeId)
getConnectedNodes(String - nodeId or edgeId) + nodeId or edgeId, [String direction])
getConnectedEdges(String diff --git a/docs/network/nodes.html b/docs/network/nodes.html index 2189b6ec..0a68ff55 100644 --- a/docs/network/nodes.html +++ b/docs/network/nodes.html @@ -809,7 +809,10 @@ network.setOptions(options); Number 1 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.
alignStringnoThis field is optional. If set this overrides the global align configuration option for this item. +
content String
itemsAlwaysDraggable
itemsAlwaysDraggableboolean or ObjectObjectWhen a boolean, applies the value only to itemsAlwaysDraggable.item.
locale