Newton Graph Library

Newton Graph Library

source

graph/graph.js

// const d3 = require('./../d3')
const d3 = require('d3')
const Cola = require('webcola')

const EventEmitter = require('events').EventEmitter

const Labels = require('./views/labels')
const Links = require('./views/links')
const Nodes = require('./views/nodes')

const defaults = {
	margin: 40,
	height: 550,
	width: 800 // can't use `window` in tests
}

/**
 * The `Graph` class holds everything together: {@link Nodes}, {@link Links}, {@link Labels} and the {@link Network}.
 */
class Graph extends EventEmitter {

	/**
	 * Note that the `opts.network` attribute is required.
	 *
	 * @param {Object} opts={}
	 * @param {Number} [opts.margin=40] - Graph margins in pixels.
	 * @param {Number} [opts.height=550] - Height of network graph in pixels.
	 * @param {Number} [opts.width=800] - Width of network graph in pixels.
	 * @param {Network} opts.network
	 * @param {String} [opts.engine = 'cola'] - Force layout engine. Can be `d3` or `cola`.
	 * @param {String} [opts.flow] - Display links in horizontal flow?
	 * @param {Boolean} [opts.draggable] - Make nodes draggable?
	 */
	constructor (opts = {}) {
		super()
		// super(opts)
		// console.log('[graph] constructor options?', opts)
		this.margin = opts.margin || defaults.margin
		this.height = opts.height || defaults.height
		this.width = (opts.width || defaults.width) - this.margin
		this.engine = opts.engine || 'cola'
		this.options = opts

		this._setNetwork(opts)

		/**
		 * @property {Labels} labels - reference to Labels used for UI interactions
		 */
		this.labels = new Labels()

		/**
		 * @property {Links} links - reference to Links used for UI interactions
		 */
		this.links = new Links()

		/**
		 * @property {Nodes} nodes - reference to Nodes used for UI interactions
		 */
		this.nodes = new Nodes()


		/**
		 * @property {Integer} renders - internal counter used to help decide whether to re-render a layout
		 */
		this.renders = 0
	}

	/**
	 * Initializes `Graph` layout, binds graph to {@link Network}, and performs first render.
	 *
	 * @listens Network#event:update
	 * @listens Links#event:enter
	 * @listens Links#event:exit
	 * @listens Nodes#event:update
	 * @listens Nodes#event:enter
	 * @listens Nodes#event:exit
	 * @listens Nodes#event:click
	 * @listens Nodes#event:mouseover
	 * @listens Nodes#event:mouseout
	 * @returns {this}
	 */
	init () {
		this._initLayout()
		this._bindGraphToViews()

		// must bind layout before network for dragging to work
		this._bindLayout()
		this._bindNetwork()

		// first render
		this.render()
		return this
	}

	// ------- NETWORK ------- //

	_setNetwork (opts) {
		if (opts.network) {
			// console.log('[graph] constructor - network set')
			this.network = opts.network
		} else {
			throw `ERROR: graph requires network for data`
		}
	}

	_bindNetwork () {
		this.network.on('update', (data) => {
			this.force
				.nodes(data.nodes)
				.links(data.links)
			this.render()
		})
	}

	// ------- FORCE ENGINES ------- //

	_initLayout () {
		this.svg = d3.select('svg')
			.attr('width', this.width)
			.attr('height', this.height)

		this._addArrows([
			'is-default',
			'is-source',
			'is-deep-source',
			'is-target'
		])

		this.force = (this.engine === 'cola')
			? this._colaForce()
			: this._d3Force()
	}

	_bindLayout () {
		// let views re-position themselves on cola `tick`
		this.force.on('tick', () => this.emit('tick'))

		// recalcuate forces if nodes count changes
		if (this.engine === 'cola') {
			this.nodes.on('enter', (s) => this._adjustForce(s))
			this.nodes.on('exit', (s) => this._adjustForce(s))
			this.links.on('enter', (s) => this._adjustForce(s))
			this.links.on('exit', (s) => this._adjustForce(s))
		}

		// make nodes draggable
		if (this.options.draggable && this.engine === 'cola') {
			this.nodes.on('update', (nodes) => nodes.call(this.force.drag))
		}

		/**
		 * @event Graph#node:mouseover
		 * @type {Node}
		 * @example
		 * graph.on('node:mouseover', (node) => {
		 * 	console.log('User mouse-overed on node ' + node.name)
		 * })
		 */
		this.nodes.on('mouseover', (n) => this.emit('node:mouseover', n))

		/**
		 * @event Graph#node:mouseout
		 * @type {Node}
		 * @example
		 * graph.on('node:mouseout', (node) => {
			* 	console.log('User mouse-outed on node ' + node.name)
			* })
			*/
		this.nodes.on('mouseout', (n) => this.emit('node:mouseout', n))

		/**
		 * @event Graph#node:click
		 * @type {Node}
		 * @example
		 * graph.on('node:click', (node) => {
			* 	console.log('User clicked on node ' + node.name)
			* })
			*/
		this.nodes.on('click', (n) => this.emit('node:click', n))
	}

	_adjustForce (selection) {
		if (selection.size() > 0) {
			this.force.start(20)
		}
	}

	/**
	 * Initialize Cola.js Engine
	 *
	 * @private
	 */
	_colaForce () {
		let force = Cola.d3adaptor(d3).size([this.width, this.height])
		force
			.nodes(this.network.get('nodes'))
			.links(this.network.get('links'))
			.avoidOverlaps(true)
			.handleDisconnected(true)
			// .symmetricDiffLinkLengths(25,0.5)
			.jaccardLinkLengths(65,0.8)

		if (this.options.flow === 'horizontal') {
			force.flowLayout('x', 100)
		}

		force.start(50)
		return force
	}


	/**
	 * Initialize d3.js Engine
	 *
	 * @private
	 */
	_d3Force () {
		let force = d3.forceSimulation(this.network.get('nodes'))
		.force('link', d3.forceLink(
			this.network.get('links'))
				.id(d => d.id)
				.distance(100)
				.strength(0.5)
		)
		.force('charge', d3.forceManyBody(-30))
		.force('center', d3.forceCenter(this.width / 2, this.height / 2))
		.force('collide', d3.forceCollide(50))
		.force('position', d3.forceRadial(20))
		return force
	}

	/**
	 * Used by View position()
	 * @private
	 */
	_bindGraphToViews () {
		this.links.bindGraph(this)
		this.nodes.bindGraph(this)
		this.labels.bindGraph(this)
	}

	// ------- RENDERS --------

	/**
	 * Renders {@link Nodes}, {@link Links} and {@link Labels} for a `Graph`.
	 * If nodes have failures, they will be highlighted with colors and animations in the graph.
	 *
	 * @param {Object} [data] - Defaults to graph's {@link Network} data.
	 */
	render (data) {
		this.renders++
		// console.log(`[graph] renders count: ${this.renders}`)
		data = (data !== undefined) ? data : this.network.get('data')

		this.links.render(data) // technically does not need nodes
		this.nodes.render(data) // technically does not need links
		this.labels.render(data) // technically does not need links

		let hasFailures = false
		data.nodes.forEach((n) => {
			if (n.status === 'down') {
				this.highlightDependencies(n)
				hasFailures = true
			}
		})
		if (!hasFailures) {
			this.resetStyles()
		}
	}

	/**
	 * Highlights dependencies, of nodes
	 *
	 * @param {Node} node - Node, whose dependencies are to be highlighted
	 * @param {Object} [options={}]
	 * @param {Boolean} [options.arrows=null] - Show directional arrows of source-target relationship?
	 */
	highlightDependencies (node, options = {}) {
		// console.log(`[graph] highlightDependencies(${node.label})`)
		this.nodes.setRelationships(node)
		this.labels.setRelationships(node)
		this.links.setRelationships(node)
		if (options.arrows) {
			this.links.showArrows(node, { color: true, showAll: false })
		}
	}

	/**
	 * Resets styles, highlights, removing colors, arrows, etc.
	 */
	resetStyles () {
		// console.log('[graph] resetStyles()')
		this.nodes.resetStyles()
		this.labels.resetStyles()
		this.links.resetStyles()
	}

	_addArrows (stylesArray) {
		// 20 for radius 6
		// 24 for radius 10
		this.svg.append("svg:defs")
			.selectAll('marker')
			.data(stylesArray)
		.enter().append('svg:marker')
			.attr('id', String)
			.attr('viewBox', '0 -4 8 8')
			.attr('refX', 20)
			.attr('refY', 0)
			.attr('markerWidth', 6)
			.attr('markerHeight', 6)
			.attr('orient', 'auto')
		.append('svg:path')
			.attr('d', 'M0,-5L10,0L0,5')
	}
}

module.exports = Graph