visjs-network
Version:
A dynamic, browser-based network visualization library.
937 lines (848 loc) • 24.9 kB
JavaScript
var util = require('./util')
var Queue = require('./Queue')
/**
* DataSet
* // TODO: add a DataSet constructor DataSet(data, options)
*
* Usage:
* var dataSet = new DataSet({
* fieldId: '_id',
* type: {
* // ...
* }
* });
*
* dataSet.add(item);
* dataSet.add(data);
* dataSet.update(item);
* dataSet.update(data);
* dataSet.remove(id);
* dataSet.remove(ids);
* var data = dataSet.get();
* var data = dataSet.get(id);
* var data = dataSet.get(ids);
* var data = dataSet.get(ids, options, data);
* dataSet.clear();
*
* A data set can:
* - add/remove/update data
* - gives triggers upon changes in the data
* - can import/export data in various data formats
*
* @param {Array} [data] Optional array with initial data
* @param {Object} [options] Available options:
* {string} fieldId Field name of the id in the
* items, 'id' by default.
* {Object.<string, string} type
* A map with field names as key,
* and the field type as value.
* {Object} queue Queue changes to the DataSet,
* flush them all at once.
* Queue options:
* - {number} delay Delay in ms, null by default
* - {number} max Maximum number of entries in the queue, Infinity by default
* @constructor DataSet
*/
function DataSet(data, options) {
// correctly read optional arguments
if (data && !Array.isArray(data)) {
options = data
data = null
}
this._options = options || {}
this._data = {} // map with data indexed by id
this.length = 0 // number of items in the DataSet
this._fieldId = this._options.fieldId || 'id' // name of the field containing id
this._type = {} // internal field types (NOTE: this can differ from this._options.type)
// all variants of a Date are internally stored as Date, so we can convert
// from everything to everything (also from ISODate to Number for example)
if (this._options.type) {
var fields = Object.keys(this._options.type)
for (var i = 0, len = fields.length; i < len; i++) {
var field = fields[i]
var value = this._options.type[field]
if (value == 'Date' || value == 'ISODate' || value == 'ASPDate') {
this._type[field] = 'Date'
} else {
this._type[field] = value
}
}
}
this._subscribers = {} // event subscribers
// add initial data when provided
if (data) {
this.add(data)
}
this.setOptions(options)
}
/**
* @param {Object} options Available options:
* {Object} queue Queue changes to the DataSet,
* flush them all at once.
* Queue options:
* - {number} delay Delay in ms, null by default
* - {number} max Maximum number of entries in the queue, Infinity by default
*/
DataSet.prototype.setOptions = function(options) {
if (options && options.queue !== undefined) {
if (options.queue === false) {
// delete queue if loaded
if (this._queue) {
this._queue.destroy()
delete this._queue
}
} else {
// create queue and update its options
if (!this._queue) {
this._queue = Queue.extend(this, {
replace: ['add', 'update', 'remove']
})
}
if (typeof options.queue === 'object') {
this._queue.setOptions(options.queue)
}
}
}
}
/**
* Subscribe to an event, add an event listener
* @param {string} event Event name. Available events: 'add', 'update',
* 'remove'
* @param {function} callback Callback method. Called with three parameters:
* {string} event
* {Object | null} params
* {string | number} senderId
*/
DataSet.prototype.on = function(event, callback) {
var subscribers = this._subscribers[event]
if (!subscribers) {
subscribers = []
this._subscribers[event] = subscribers
}
subscribers.push({
callback: callback
})
}
/**
* Unsubscribe from an event, remove an event listener
* @param {string} event
* @param {function} callback
*/
DataSet.prototype.off = function(event, callback) {
var subscribers = this._subscribers[event]
if (subscribers) {
this._subscribers[event] = subscribers.filter(
listener => listener.callback != callback
)
}
}
/**
* Trigger an event
* @param {string} event
* @param {Object | null} params
* @param {string} [senderId] Optional id of the sender.
* @private
*/
DataSet.prototype._trigger = function(event, params, senderId) {
if (event == '*') {
throw new Error('Cannot trigger event *')
}
var subscribers = []
if (event in this._subscribers) {
subscribers = subscribers.concat(this._subscribers[event])
}
if ('*' in this._subscribers) {
subscribers = subscribers.concat(this._subscribers['*'])
}
for (var i = 0, len = subscribers.length; i < len; i++) {
var subscriber = subscribers[i]
if (subscriber.callback) {
subscriber.callback(event, params, senderId || null)
}
}
}
/**
* Add data.
* Adding an item will fail when there already is an item with the same id.
* @param {Object | Array} data
* @param {string} [senderId] Optional sender id
* @return {Array.<string|number>} addedIds Array with the ids of the added items
*/
DataSet.prototype.add = function(data, senderId) {
var addedIds = [],
id,
me = this
if (Array.isArray(data)) {
// Array
for (var i = 0, len = data.length; i < len; i++) {
id = me._addItem(data[i])
addedIds.push(id)
}
} else if (data && typeof data === 'object') {
// Single item
id = me._addItem(data)
addedIds.push(id)
} else {
throw new Error('Unknown dataType')
}
if (addedIds.length) {
this._trigger('add', { items: addedIds }, senderId)
}
return addedIds
}
/**
* Update existing items. When an item does not exist, it will be created
* @param {Object | Array} data
* @param {string} [senderId] Optional sender id
* @return {Array.<string|number>} updatedIds The ids of the added or updated items
* @throws {Error} Unknown Datatype
*/
DataSet.prototype.update = function(data, senderId) {
var addedIds = []
var updatedIds = []
var oldData = []
var updatedData = []
var me = this
var fieldId = me._fieldId
var addOrUpdate = function(item) {
var id = item[fieldId]
if (me._data[id]) {
var oldItem = util.extend({}, me._data[id])
// update item
id = me._updateItem(item)
updatedIds.push(id)
updatedData.push(item)
oldData.push(oldItem)
} else {
// add new item
id = me._addItem(item)
addedIds.push(id)
}
}
if (Array.isArray(data)) {
// Array
for (var i = 0, len = data.length; i < len; i++) {
if (data[i] && typeof data[i] === 'object') {
addOrUpdate(data[i])
} else {
console.warn(
'Ignoring input item, which is not an object at index ' + i
)
}
}
} else if (data && typeof data === 'object') {
// Single item
addOrUpdate(data)
} else {
throw new Error('Unknown dataType')
}
if (addedIds.length) {
this._trigger('add', { items: addedIds }, senderId)
}
if (updatedIds.length) {
var props = { items: updatedIds, oldData: oldData, data: updatedData }
// TODO: remove deprecated property 'data' some day
//Object.defineProperty(props, 'data', {
// 'get': (function() {
// console.warn('Property data is deprecated. Use DataSet.get(ids) to retrieve the new data, use the oldData property on this object to get the old data');
// return updatedData;
// }).bind(this)
//});
this._trigger('update', props, senderId)
}
return addedIds.concat(updatedIds)
}
// prettier-ignore
/**
* Get a data item or multiple items.
*
* Usage:
*
* get()
* get(options: Object)
*
* get(id: number | string)
* get(id: number | string, options: Object)
*
* get(ids: number[] | string[])
* get(ids: number[] | string[], options: Object)
*
* Where:
*
* {number | string} id The id of an item
* {number[] | string{}} ids An array with ids of items
* {Object} options An Object with options. Available options:
* {string} [returnType] Type of data to be returned.
* Can be 'Array' (default) or 'Object'.
* {Object.<string, string>} [type]
* {string[]} [fields] field names to be returned
* {function} [filter] filter items
* {string | function} [order] Order the items by a field name or custom sort function.
* @param {Array} args
* @returns {DataSet}
* @throws Error
*/
DataSet.prototype.get = function(args) { // eslint-disable-line no-unused-vars
var me = this
// parse the arguments
var id, ids, options
var firstType = util.getType(arguments[0])
if (firstType == 'String' || firstType == 'Number') {
// get(id [, options])
id = arguments[0]
options = arguments[1]
} else if (firstType == 'Array') {
// get(ids [, options])
ids = arguments[0]
options = arguments[1]
} else {
// get([, options])
options = arguments[0]
}
// determine the return type
var returnType
if (options && options.returnType) {
var allowedValues = ['Array', 'Object']
returnType =
allowedValues.indexOf(options.returnType) == -1
? 'Array'
: options.returnType
} else {
returnType = 'Array'
}
// build options
var type = (options && options.type) || this._options.type
var filter = options && options.filter
var items = [],
item,
itemIds,
itemId,
i,
len
// convert items
if (id != undefined) {
// return a single item
item = me._getItem(id, type)
if (item && filter && !filter(item)) {
item = null
}
} else if (ids != undefined) {
// return a subset of items
for (i = 0, len = ids.length; i < len; i++) {
item = me._getItem(ids[i], type)
if (!filter || filter(item)) {
items.push(item)
}
}
} else {
// return all items
itemIds = Object.keys(this._data)
for (i = 0, len = itemIds.length; i < len; i++) {
itemId = itemIds[i]
item = me._getItem(itemId, type)
if (!filter || filter(item)) {
items.push(item)
}
}
}
// order the results
if (options && options.order && id == undefined) {
this._sort(items, options.order)
}
// filter fields of the items
if (options && options.fields) {
var fields = options.fields
if (id != undefined) {
item = this._filterFields(item, fields)
} else {
for (i = 0, len = items.length; i < len; i++) {
items[i] = this._filterFields(items[i], fields)
}
}
}
// return the results
if (returnType == 'Object') {
var result = {},
resultant
for (i = 0, len = items.length; i < len; i++) {
resultant = items[i]
result[resultant.id] = resultant
}
return result
} else {
if (id != undefined) {
// a single item
return item
} else {
// just return our array
return items
}
}
}
/**
* Get ids of all items or from a filtered set of items.
* @param {Object} [options] An Object with options. Available options:
* {function} [filter] filter items
* {string | function} [order] Order the items by
* a field name or custom sort function.
* @return {Array.<string|number>} ids
*/
DataSet.prototype.getIds = function(options) {
var data = this._data,
filter = options && options.filter,
order = options && options.order,
type = (options && options.type) || this._options.type,
itemIds = Object.keys(data),
i,
len,
id,
item,
items,
ids = []
if (filter) {
// get filtered items
if (order) {
// create ordered list
items = []
for (i = 0, len = itemIds.length; i < len; i++) {
id = itemIds[i]
item = this._getItem(id, type)
if (filter(item)) {
items.push(item)
}
}
this._sort(items, order)
for (i = 0, len = items.length; i < len; i++) {
ids.push(items[i][this._fieldId])
}
} else {
// create unordered list
for (i = 0, len = itemIds.length; i < len; i++) {
id = itemIds[i]
item = this._getItem(id, type)
if (filter(item)) {
ids.push(item[this._fieldId])
}
}
}
} else {
// get all items
if (order) {
// create an ordered list
items = []
for (i = 0, len = itemIds.length; i < len; i++) {
id = itemIds[i]
items.push(data[id])
}
this._sort(items, order)
for (i = 0, len = items.length; i < len; i++) {
ids.push(items[i][this._fieldId])
}
} else {
// create unordered list
for (i = 0, len = itemIds.length; i < len; i++) {
id = itemIds[i]
item = data[id]
ids.push(item[this._fieldId])
}
}
}
return ids
}
/**
* Returns the DataSet itself. Is overwritten for example by the DataView,
* which returns the DataSet it is connected to instead.
* @returns {DataSet}
*/
DataSet.prototype.getDataSet = function() {
return this
}
/**
* Execute a callback function for every item in the dataset.
* @param {function} callback
* @param {Object} [options] Available options:
* {Object.<string, string>} [type]
* {string[]} [fields] filter fields
* {function} [filter] filter items
* {string | function} [order] Order the items by
* a field name or custom sort function.
*/
DataSet.prototype.forEach = function(callback, options) {
var filter = options && options.filter,
type = (options && options.type) || this._options.type,
data = this._data,
itemIds = Object.keys(data),
i,
len,
item,
id
if (options && options.order) {
// execute forEach on ordered list
var items = this.get(options)
for (i = 0, len = items.length; i < len; i++) {
item = items[i]
id = item[this._fieldId]
callback(item, id)
}
} else {
// unordered
for (i = 0, len = itemIds.length; i < len; i++) {
id = itemIds[i]
item = this._getItem(id, type)
if (!filter || filter(item)) {
callback(item, id)
}
}
}
}
/**
* Map every item in the dataset.
* @param {function} callback
* @param {Object} [options] Available options:
* {Object.<string, string>} [type]
* {string[]} [fields] filter fields
* {function} [filter] filter items
* {string | function} [order] Order the items by
* a field name or custom sort function.
* @return {Object[]} mappedItems
*/
DataSet.prototype.map = function(callback, options) {
var filter = options && options.filter,
type = (options && options.type) || this._options.type,
mappedItems = [],
data = this._data,
itemIds = Object.keys(data),
i,
len,
id,
item
// convert and filter items
for (i = 0, len = itemIds.length; i < len; i++) {
id = itemIds[i]
item = this._getItem(id, type)
if (!filter || filter(item)) {
mappedItems.push(callback(item, id))
}
}
// order items
if (options && options.order) {
this._sort(mappedItems, options.order)
}
return mappedItems
}
/**
* Filter the fields of an item
* @param {Object | null} item
* @param {string[]} fields Field names
* @return {Object | null} filteredItem or null if no item is provided
* @private
*/
DataSet.prototype._filterFields = function(item, fields) {
if (!item) {
// item is null
return item
}
var filteredItem = {},
itemFields = Object.keys(item),
len = itemFields.length,
i,
field
if (Array.isArray(fields)) {
for (i = 0; i < len; i++) {
field = itemFields[i]
if (fields.indexOf(field) != -1) {
filteredItem[field] = item[field]
}
}
} else {
for (i = 0; i < len; i++) {
field = itemFields[i]
if (fields.hasOwnProperty(field)) {
filteredItem[fields[field]] = item[field]
}
}
}
return filteredItem
}
/**
* Sort the provided array with items
* @param {Object[]} items
* @param {string | function} order A field name or custom sort function.
* @private
*/
DataSet.prototype._sort = function(items, order) {
if (util.isString(order)) {
// order by provided field name
var name = order // field name
items.sort(function(a, b) {
var av = a[name]
var bv = b[name]
return av > bv ? 1 : av < bv ? -1 : 0
})
} else if (typeof order === 'function') {
// order by sort function
items.sort(order)
}
// TODO: extend order by an Object {field:string, direction:string}
// where direction can be 'asc' or 'desc'
else {
throw new TypeError('Order must be a function or a string')
}
}
/**
* Remove an object by pointer or by id
* @param {string | number | Object | Array.<string|number>} id Object or id, or an array with
* objects or ids to be removed
* @param {string} [senderId] Optional sender id
* @return {Array.<string|number>} removedIds
*/
DataSet.prototype.remove = function(id, senderId) {
var removedIds = [],
removedItems = [],
ids = [],
i,
len,
itemId,
item
// force everything to be an array for simplicity
ids = Array.isArray(id) ? id : [id]
for (i = 0, len = ids.length; i < len; i++) {
item = this._remove(ids[i])
if (item) {
itemId = item[this._fieldId]
if (itemId != undefined) {
removedIds.push(itemId)
removedItems.push(item)
}
}
}
if (removedIds.length) {
this._trigger(
'remove',
{ items: removedIds, oldData: removedItems },
senderId
)
}
return removedIds
}
/**
* Remove an item by its id
* @param {number | string | Object} id id or item
* @returns {number | string | null} id
* @private
*/
DataSet.prototype._remove = function(id) {
var item, ident
// confirm the id to use based on the args type
if (util.isNumber(id) || util.isString(id)) {
ident = id
} else if (id && typeof id === 'object') {
ident = id[this._fieldId] // look for the identifier field using _fieldId
}
// do the remove if the item is found
if (ident !== undefined && this._data[ident]) {
item = this._data[ident]
delete this._data[ident]
this.length--
return item
}
return null
}
/**
* Clear the data
* @param {string} [senderId] Optional sender id
* @return {Array.<string|number>} removedIds The ids of all removed items
*/
DataSet.prototype.clear = function(senderId) {
var i, len
var ids = Object.keys(this._data)
var items = []
for (i = 0, len = ids.length; i < len; i++) {
items.push(this._data[ids[i]])
}
this._data = {}
this.length = 0
this._trigger('remove', { items: ids, oldData: items }, senderId)
return ids
}
/**
* Find the item with maximum value of a specified field
* @param {string} field
* @return {Object | null} item Item containing max value, or null if no items
*/
DataSet.prototype.max = function(field) {
var data = this._data,
itemIds = Object.keys(data),
max = null,
maxField = null,
i,
len
for (i = 0, len = itemIds.length; i < len; i++) {
var id = itemIds[i]
var item = data[id]
var itemField = item[field]
if (itemField != null && (!max || itemField > maxField)) {
max = item
maxField = itemField
}
}
return max
}
/**
* Find the item with minimum value of a specified field
* @param {string} field
* @return {Object | null} item Item containing max value, or null if no items
*/
DataSet.prototype.min = function(field) {
var data = this._data,
itemIds = Object.keys(data),
min = null,
minField = null,
i,
len
for (i = 0, len = itemIds.length; i < len; i++) {
var id = itemIds[i]
var item = data[id]
var itemField = item[field]
if (itemField != null && (!min || itemField < minField)) {
min = item
minField = itemField
}
}
return min
}
/**
* Find all distinct values of a specified field
* @param {string} field
* @return {Array} values Array containing all distinct values. If data items
* do not contain the specified field are ignored.
* The returned array is unordered.
*/
DataSet.prototype.distinct = function(field) {
var data = this._data
var itemIds = Object.keys(data)
var values = []
var fieldType = (this._options.type && this._options.type[field]) || null
var count = 0
var i, j, len
for (i = 0, len = itemIds.length; i < len; i++) {
var id = itemIds[i]
var item = data[id]
var value = item[field]
var exists = false
for (j = 0; j < count; j++) {
if (values[j] == value) {
exists = true
break
}
}
if (!exists && value !== undefined) {
values[count] = value
count++
}
}
if (fieldType) {
for (i = 0, len = values.length; i < len; i++) {
values[i] = util.convert(values[i], fieldType)
}
}
return values
}
/**
* Add a single item. Will fail when an item with the same id already exists.
* @param {Object} item
* @return {string} id
* @private
*/
DataSet.prototype._addItem = function(item) {
var id = item[this._fieldId]
if (id != undefined) {
// check whether this id is already taken
if (this._data[id]) {
// item already exists
throw new Error('Cannot add item: item with id ' + id + ' already exists')
}
} else {
// generate an id
id = util.randomUUID()
item[this._fieldId] = id
}
var d = {},
fields = Object.keys(item),
i,
len
for (i = 0, len = fields.length; i < len; i++) {
var field = fields[i]
var fieldType = this._type[field] // type may be undefined
d[field] = util.convert(item[field], fieldType)
}
this._data[id] = d
this.length++
return id
}
/**
* Get an item. Fields can be converted to a specific type
* @param {string} id
* @param {Object.<string, string>} [types] field types to convert
* @return {Object | null} item
* @private
*/
DataSet.prototype._getItem = function(id, types) {
var field, value, i, len
// get the item from the dataset
var raw = this._data[id]
if (!raw) {
return null
}
// convert the items field types
var converted = {},
fields = Object.keys(raw)
if (types) {
for (i = 0, len = fields.length; i < len; i++) {
field = fields[i]
value = raw[field]
converted[field] = util.convert(value, types[field])
}
} else {
// no field types specified, no converting needed
for (i = 0, len = fields.length; i < len; i++) {
field = fields[i]
value = raw[field]
converted[field] = value
}
}
if (!converted[this._fieldId]) {
converted[this._fieldId] = raw.id
}
return converted
}
/**
* Update a single item: merge with existing item.
* Will fail when the item has no id, or when there does not exist an item
* with the same id.
* @param {Object} item
* @return {string} id
* @private
*/
DataSet.prototype._updateItem = function(item) {
var id = item[this._fieldId]
if (id == undefined) {
throw new Error(
'Cannot update item: item has no id (item: ' + JSON.stringify(item) + ')'
)
}
var d = this._data[id]
if (!d) {
// item doesn't exist
throw new Error('Cannot update item: no item with id ' + id + ' found')
}
// merge with current item
var fields = Object.keys(item)
for (var i = 0, len = fields.length; i < len; i++) {
var field = fields[i]
var fieldType = this._type[field] // type may be undefined
d[field] = util.convert(item[field], fieldType)
}
return id
}
module.exports = DataSet