Newton Graph Library

Newton Graph Library

source

network/network.js

const EventEmitter = require('events').EventEmitter

/**
 * Data Wrapper for Graphs, useful for dynamically calculating links between nodes.
 * As describe in the [WebCola Wiki](https://github.com/tgdwyer/WebCola/wiki/General-API-notes), the layout adapter
 * follows [d3.layout.force](https://github.com/mbostock/d3/wiki/Force-Layout) conventions and joins nodes via (JavaScript) reference or array indexes, which normally looks like this:
 *
 * ```javascript
 * const links = [
 * 	{ source: 0, target: 1 },
 * 	{ source: 3, target: 5 }
 * ]
 * ```
 *
 * This wrapper lets you calculate links dynamically based on a reference key, e.g. `id`, so we can use this kind of structure instead:
 *
 * ```javascript
 * const links = [
 * 	{ source: 'A', target: 'B' },
 * 	{ source: 'frontend', target: 'middleware' }
 * ]
 * ```
 *
 */
class Network extends EventEmitter {
	/**
	 * Creates a Network
	 *
	 * @param {Array} nodes - Array of {@link Node}s
	 * @param {Array} links - Array of {@link Link}s - relationships between nodes using the reference key, or array indexes
	 * @param {Object} options
	 * @param {String} options.uid - name of the property of unique identifier, e.g. 'id'
	 */
	constructor (nodes, links, options = {}) {
		super()

		/**
		 * Unique identifier to reference nodes when calculating links
		 *
		 * @property {String} _uid
		 * @private
		 */
		this._uid = options.uid || 'id'

		/**
		 * Array that maps relationships between nodes using
		 * references to array indexes (original d3.js interface).
		 * @property {Array} _links
		 * @private
		 */
		this._links = []

		/**
		 * Array of nodes. See d3.js documentation.
		 * @property {Array} _nodes
		 * @private
		 */
		this._nodes = []

		this._setData(nodes, links, { event: 'created' })
	}

	/**
	 * Updates entire network data set
	 *
	 * @param {Array} nodes - nodes data
	 * @param {Array} links - links data
	 * @param {String} [opts.event = 'update'] - event name after setting data
	 * @param {Boolean} [opts.publish = true] - publish event after resetting data?
	 */
	updateData (nodes, links, opts = {}) {
		// console.log('updateData()', nodes, links)
		// console.log(`[DEBUG]: updateData(nodes, links)`)
		// console.log('    nodes:', nodes)
		// console.log('    links:', links)
		this._setData(nodes, links, opts)
	}

	/**
	 * Fetches network attributes
	 * Currently used for first binding or rendering
	 *
	 * @param {String} attr - attribute to return. Valid attributes are `links` or `nodes`.
	 * @return {Object|Array|null}
	 */
	get (attr) {
		if (attr === 'data')
			return { links: this._links, nodes: this._nodes }
		else if (attr === 'links')
			return this._links
		else if (attr === 'nodes')
			return this._nodes
		else
			return null
	}

	_setData (nodes, links, opts = {}) {
		// console.log(`[DEBUG]: _setData(nodes, links)`)
		// console.log('    nodes:', nodes)
		// console.log('    links:', links)
		const defaults = {
			publish: true,
			event: 'update'
		}
		opts = Object.assign({}, defaults, opts)

		this._nodes = nodes
		this._links = links
		this._validateLinks()
		this._mapNeighbors()
		if (opts.publish) {
			this._publish(opts.event)
		}
	}

	// =========== Nodes ===========

	/**
	 * Find node by id
	 *
	 * @param {String} id
	 * @return {Node} node
	 */
	findNodeById (id) {
		// console.log(`findNodeById(${id})`)
		return this._nodes.find((n) => n[this._uid] === id)
	}

	/**
	 * Finds index of node either by it's unique identifier, e.g. `id` or the node data object itself.
	 *
	 * @param {String|Object} node - id or data object
	 * @return {Integer} index of matched node or `-1` if not found
	 */
	findNodeIndex (node) {
		let id = (typeof node === 'object')
			? node[this._uid]
			: node
		return this._nodes.findIndex((n) => n[this._uid] === id)
	}

	/**
	 * Updates data on a specific node
	 *
	 * @param {String|Node} n - either `id` of node, or the object itself to update
	 * @param {Object} attrs - node data attributes to change. This is merged onto existing attributes, so you only need to pass in updated values.
	 * @return {this}
	 */
	updateNode (n, attrs) {
		let node = (typeof n === 'string')
			? this.findNodeById(n)
			: n
		Object.assign(node, attrs)
		this._publish('update')
		return this
	}

	/**
	 * Removes node _and_ its links from the graph.
	 *
	 * @param {Node} node
	 */
	removeNode (node) {
		const i = this.findNodeIndex(node)
		this._nodes.splice(i, 1)
		this.removeLinks(node)
	}

	/**
	 * Removes node by its `id`.
	 * @param {String} id - id of node to remove
	 */
	removeNodeById (id) {
		let node = this.findNodeById(id)
		this.removeNode(node)
		this._publish('update')
	}

	// =========== Links ===========

	/**
	 * Finds links a given node has. Example results are `[{source: node, target: node}]`
	 *
	 * @param {Node} node
	 * @return {Array} of {@link Link} objets
	 */
	findLinks (node) {
		let links = []
		this._links.forEach((link, i) => {
			if (link.source === node || link.target === node) {
				links.push(link)
			}
		})
		return links
	}

	/**
	 * Removes links for a given node
	 *
	 * @param {Node} node
	 */
	removeLinks (node) {
		let links = this._links
		for (let i = links.length; i--; i >= 0) {
			if (links[i].source === node || links[i].target === node) {
				links.splice(i, 1)
			}
		}
	}

	_isIndexBasedLink (link) {
		let hasZeroIndex = (link.source === 0) || (link.target === 0)
		let isNumber = (typeof link.source === 'number') && (typeof link.target === 'number')
		return isNumber || hasZeroIndex
	}

	/**
	 * Validates links:
	 * - validates have `source` and `target` properties
	 * - converts references by `id` or array index to nodes themselves
	 * - removes links with invalid (missing) source or target nodes
	 *
	 * @private
	 */
	_validateLinks () {
		// console.log('_validateLinks()')
		let missing = []
		this._links.forEach((link, i) => {
			verifyLinkFormat(link)
			let isIndexBased = this._isIndexBasedLink(link)
			let s = isIndexBased ? this._nodes[link.source] : this.findNodeById(link.source)
			let t = isIndexBased ? this._nodes[link.target] : this.findNodeById(link.target)

			if (s !== undefined && t !== undefined) {
				this._links[i] = { source: s, target: t }
			} else {
				// console.log('link is invalid', link)
				missing.push(i)
			}
		})

		this._removeLinksByIndex(missing)
	}

	/**
	 * Removes array by indexes
	 *
	 * @private
	 * @param {Array} indexes - array of indexes sorted low to high, e.g. [1, 5, 10]
	 */
	_removeLinksByIndex (indexes) {
		for (let i = indexes.length; i--; i >= 0) {
			this._links.splice(indexes[i], 1)
		}
	}

	// =========== Events =========== //

	/**
	 * Emits an event
	 *
	 * @private
	 * @param {String} eventName - Event Name, e.g. `update`
	 */
	_publish (eventName) {
		// console.log(`[network event] publish: '${eventName}'`)

		/**
		 * Update event which passes on network data so graph elements,
		 * nodes and links can update themselves based on latest
		 * real-time data.
		 *
		 * @event Network#update
		 * @type {Object}
		 * @property {Array} nodes
		 * @property {Array} links
		 */
		this.emit(eventName, {
			nodes: this._nodes,
			links: this._links
		})
	}

	// =========== Neighbors =========== //

	_mapNeighbors () {
		// console.log('[network] _mapNeighbors()')
		this._neighbors = {}
		this._links.forEach((d) => {
			this._neighbors[`${d.source.id},${d.target.id}`] = true
		})
	}

	/**
	 * Examines the a relationship between the two given nodes. Will return `true` if there is a a source<->target relationship or if the nodes are equal, i.e. the same.
	 *
	 * @param {Node} a - first node
	 * @param {Node} b - second node
	 * @return {Boolean}
	 */
	areNeighbors (a, b) {
		return this.isTargetNeighbor(a, b)
			|| this.isSourceNeighbor(a, b)
			|| this.isEqualNode(a, b)
	}

	/**
	 * Examines if there is a `source -> target` relationship between two specific nodes.
	 *
	 * @param {Node} a - first node
	 * @param {Node} b - second node
	 * @return {Boolean}
	 */
	isSourceNeighbor (a, b) {
		return this._neighbors[`${a.id},${b.id}`] !== undefined
	}

	/**
	 * Examines if node a is includeed in node b's source tree, i.e. if a `source -> ... -> target` relationship between the two nodes.
	 *
	 * @param {Node} a - potential source node
	 * @param {Node} b - potential target node
	 * @return {Boolean}
	 */
	isDeepSourceNeighbor (a, b) {
		let sources = this.findDeepSources(b)
		return sources.length > 0
			? sources.includes(a)
			: false
	}

	/**
	 * Examines if there is a `b -> a` relationship between two specific nodes.
	 *
	 * @param {Node} a - potential `target` node
	 * @param {Node} b - potential `source` node
	 * @return {Boolean}
	 */
	isTargetNeighbor (a, b) {
		return this._neighbors[`${b.id},${a.id}`]
	}

	/**
	 * Returns true if the nodes examined are equal, i.e. the same node
	 *
	 * @param {String} a - id of node A
	 * @param {String} b - id of node B
	 * @return {Boolean}
	 */
	isEqualNode (a, b) {
		return a.id === b.id
	}

	/**
	 * Returns Array of nodes in the source tree of a specific node.
	 *
	 * @param {Node} n - node to get source tree
	 * @return {Array}
	 */
	findSources (n) {
		let sources = []
		this._links.forEach((i) => {
			if (i.target === n) {
				sources.push(i.source)
			}
		})
		return sources
	}

	/**
	 * Recursively finds all deep sources of a node, i.e. does not include direct source.
	 *
	 * @param {Node} n - node to get source tree
	 * @param {Array} [sources=[]]
	 * @param {Array} [level=0]
	 * @return {Array}
	 */
	findDeepSources (n, sources = [], level = 0) {
		// console.log(`-- findDeepSources(${n.label}) --`)
		this._links.forEach((l) => {
			if (l.target === n) {
				if (level !== 0) {
					sources.push(l.source)
				}

				let parents = this.findSources(n)
				if (parents.length === 0) { return }
				parents.forEach((a) => this.findDeepSources(a, sources, level + 1))
			}
		})

		const uniques = [...new Set(sources)]
		return uniques
	}

	/**
	 * Returns `true` if link can be found in the node's source tree.
	 *
	 * @param {Link} link
	 * @param {Node} n
	 * @return {Boolean}
	 */
	isDeepSourceLink (link, n) {
		let sourceIsASource = this.isDeepSourceNeighbor(link.source, n)
		let targetIsASource = this.isDeepSourceNeighbor(link.target, n) || this.isSourceNeighbor(link.target, n)
		let isDeepLink = sourceIsASource && targetIsASource
		// console.log(`Does [${link.source.label}] -> [${link.target.label}] deep link to [${n.label}]? `, isDeepLink)
		return isDeepLink
	}

	/**
	 * Returns relationship type between node and its neighbor. Will return one of the following:
	 * | Key | Relationship type |
	 * |:--|:--|
	 * | `'is-source'` | node -> neighbor |
	 * | `'is-deep-source'` | node -> … -> neighbor |
	 * | `'is-target'` | neighbor -> node |
	 * | `'is-same-node'` | node === neighbor |
	 * | `'has-no-relationship'` | There is no relationship between the two nodes |
	 *
	 * @param {Node} node
	 * @param {Node} neighbor
	 * @return {String}
	 */
	getRelationship (node, neighbor) {
		let rel = ''
		if (this.isSourceNeighbor(node, neighbor)) {
			rel = 'is-source'
		} else if (this.isDeepSourceNeighbor(node, neighbor)) {
			rel = 'is-deep-source'
		} else if (this.isTargetNeighbor(node, neighbor)) {
			rel = 'is-target'
		} else if (this.isEqualNode(node, neighbor)) {
			rel = 'is-same-node'
		} else {
			rel = 'has-no-relationship'
		}
		return rel
	}
}

function verifyLinkFormat (link) {
	if (!(link.hasOwnProperty('source') && link.hasOwnProperty('target'))) {
		throw {
			error: 'Improperly formatted link',
			link: link
		}
	}
}
module.exports = Network