Source: beadnet.js

import getName from "./namegenerator.js";
import log from "./logger.js";
import extendDefaultOptions from "./options.js";

import {
	InsufficientBalanceError
}
from "./errors.js";

const getRandomNumber = function(max) {
	return Math.floor(Math.random() * max);
};

const COMMAND_MAPPING = {
	ADD_NODE: {
		fnName: "addNode",
		numArgs: 1
	},
	ADD_NODES: {
		fnName: "addNodes",
		numArgs: 1
	},
	REMOVE_NODE: {
		fnName: "removeNode",
		numArgs: 1
	},
	ADD_CHANNEL: {
		fnName: "addChannel",
		numArgs: 1
	},
	ADD_CHANNELS: {
		fnName: "addChannels",
		numArgs: 1
	},
	REMOVE_CHANNEL: {
		fnName: "removeChannel",
		numArgs: 2
	},
	CHANGE_CHANNEL_SOURCE_BALANCE: {
		fnName: "changeChannelSourceBalance",
		numArgs: 3
	},
	CHANGE_CHANNEL_TARGET_BALANCE: {
		fnName: "changeChannelTargetBalance",
		numArgs: 3
	},
	HIGHLIGHT_CHANNEL: {
		fnName: "highlightChannel",
		numArgs: 3
	},
	MOVE_BEADS: {
		fnName: "moveBeads",
		numArgs: 4
	},
};

/**
 * Beadnet draws nodes, channels between nodes and channel balances using d3js.
 * Channel balances are drawn as beads on a string and can be moved to visualize
 * funds moving in the Lightning Network.
 */
class Beadnet {

	/**
	 * Create a new BeadNet chart.
	 *
	 * @param {Object} options
	 */
	constructor(options) {
		this._opt = extendDefaultOptions(options);
		log.debug("initializing beadnet with options: ", this._opt);

		/* find the parent container DOM element and insert an SVG */
		this.container = document.querySelector(this._opt.container.selector);
		this.svg = d3.select(this.container)
			.append("svg")
			.attr("class", "beadnet");

		this.updateSVGSize();

		/* create svg root element called with class "chart" and initial  */
		this.chart = this.svg.append("g")
			.attr("class", "chart")
			.attr("transform", "translate(0,0) scale(1)");

		/* create a SVG-container-element for all nodes and all channels */
		this.channelContainer = this.chart.append("g").attr("class", "channels");
		this.nodeContainer = this.chart.append("g").attr("class", "nodes");

		this._nodes = [];
		this._channels = [];
		this.beadElements = [];

		this.simulation = this._createSimulation();

		this.updateSimulationCenter();

		this.behaviors = this.createBehaviors();
		this.svg.call(this.behaviors.zoom);

		this._updateNodes();

		if (this._opt.presentation) {
			this._initializePresentation();
		}

		window.addEventListener("resize", this.onResize.bind(this));
	}

	/**
	 * Return the node with the given id.
	 *
	 * @param {String} id - the id of the node to find.
	 * @returns {Node|undefined}
	 */
	_getNodeById(id) {
		return this._nodes.find((node) => node.id == id);
	}

	/**
	 * Return the channel with the given id.
	 *
	 * @param {String} id - the id of the node to find.
	 * @returns {Channel|undefined}
	 */
	_getChannelById(id) {
		return this._channels.find((ch) => ch.id == id);
	}

	/**
	 * Creates a new simulation.
	 *
	 * @returns {d3.forceSimulation} simulation
	 * @private
	 */
	_createSimulation() {
		// return d3.forceSimulation()
		// 	.nodes(this._nodes)
		// 	.alphaDecay(0.1)
		// 	//.force("x", d3.forceX().strength(0))
		// 	//.force("y", d3.forceY().strength(1))
		// 	.force("charge", d3.forceManyBody().strength(-1000).distanceMin(this.forceDistance).distanceMax(3*this.forceDistance))
		// 	//.force("collide", d3.forceCollide(this.forceDistance/6))
		// 	.force("link", d3.forceLink(this._channels).distance(this.forceDistance))
		// 	.force("center", d3.forceCenter(this.width / 2, this.height / 2))
		// 	.alphaTarget(0)
		// 	.on("tick", this._ticked.bind(this));

		return d3.forceSimulation(this._nodes)
			.force("charge", d3.forceManyBody().strength(-3000))
			.force("link", d3.forceLink(this._channels).strength(0.005).distance(this.forceDistance))
			.force("x", d3.forceX())
			.force("y", d3.forceY())
			.alphaTarget(0)
			.on("tick", this._ticked.bind(this));
	}

	/**
	 * Updates the size of the SVG element to use the full size of it's container.
	 */
	updateSVGSize() {
		this.width = +this.container.clientWidth;
		this.height = +this.container.clientHeight;
		this.forceDistance = (this.width + this.height) * .1;
		this.svg
			.attr("width", this.width)
			.attr("height", this.height);
	}

	/**
	 * Handles a resize event of the window/container.
	 */
	onResize() {
		this.updateSVGSize();
		this.updateSimulationCenter();
		this.createBehaviors();
	}

	/**
	 * Creates the d3js behaviours for zoom and drag&drop.
	 */
	createBehaviors() {
		return {

			zoom: d3.zoom()
				.scaleExtent([0.1, 5, 4])
				.on("zoom", () => this.chart.attr("transform", d3.event.transform)),

			drag: d3.drag()
				.on("start", this._onDragStart.bind(this))
				.on("drag", this._onDragged.bind(this))
				.on("end", this._onDragendEnd.bind(this))
		}
	}

	/**
	 * Forces the simulation to restart at the center of the SVG area.
	 */
	updateSimulationCenter() {
		const centerX = this.svg.attr("width") / 2;
		const centerY = this.svg.attr("height") / 2;
		this.simulation
			.force("center", d3.forceCenter(centerX, centerY))
			.restart();
	}

	/**
	 * Update DOM elements after this._nodes has been updated.
	 * This creates the SVG repensentation of a node.
	 *
	 * @private
	 */
	_updateNodes() {
		const opt = this._opt;

		console.log("_updateNodes: ", this._nodes);

		this._nodeElements = this.nodeContainer
			.selectAll(".node")
			.data(this._nodes, (data) => data.id);

		/* remove deleted nodes */
		this._nodeElements.exit().transition().duration(1000).style("opacity", 0).remove();

		/* create new nodes */
		let nodeParent = this._nodeElements.enter().append("g")
			.attr("class", "node")
			.attr("id", (data) => data.id)
			.attr("balance", (data) => data.balance)
			.style("stroke", opt.nodes.strokeColor)
			.style("stroke-width", opt.nodes.strokeWidth);

		nodeParent.append("circle")
			.attr("class", "node-circle")
			.attr("fill", (data) => data.color)
			.attr("r", opt.nodes.radius)
			.style("cursor", "pointer");

		nodeParent.append("text")
			.style("stroke-width", 0.5)
			.attr("class", "node-text-id")
			.attr("stroke", opt.container.backgroundColor)
			.attr("fill", opt.container.backgroundColor)
			.attr("font-family", "sans-serif")
			.attr("font-size", "15px")
			.attr("y", "0px")
			.attr("text-anchor", "middle")
			.attr("pointer-events", "none")
			.text((d) => d.id);

		nodeParent.append("text")
			.style("stroke-width", 0.5)
			.attr("class", "node-text-balance")
			.attr("stroke", opt.container.backgroundColor)
			.attr("fill", opt.container.backgroundColor)
			.attr("font-family", "sans-serif")
			.attr("font-size", "12px")
			.attr("y", "15px")
			.attr("text-anchor", "middle")
			.attr("pointer-events", "none")
			.text((d) => d.balance);

		nodeParent.call(this.behaviors.drag);

		/* update existing nodes */
		this._nodeElements
			.attr("balance", (d) => d.balance)
			.selectAll(".node-text-balance")
			.text((d) => d.balance);

		this.simulation
			.nodes(this._nodes)
			.alphaTarget(1)
			.restart();

		this._nodeElements = this.nodeContainer
			.selectAll(".node");

		return this._nodeElements;
	}

	/**
	 * Adds a new node to the network.
	 *
	 * @param {Node} node
	 * @returns {Beadnet}
	 */
	addNode(node) {
		node = node || {};

		/* initialize with default values */
		node.id = node.id || getName();
		node.balance = node.balance || getRandomNumber(100);
		node.color = this._opt.colorScheme(this._nodes.length % 20 + 1);
		//node.color = d3.scaleOrdinal(d3.schemeCategory20)(this._nodes.length % 20 + 1)

		/* save to nodes array */
		this._nodes.push(node);
		this._updateNodes();

		/* make this function chainable */
		return this;
	}

	/**
	 * Adds multible new nodes to the network.
	 *
	 * @param {Array<Node>} nodes
	 * @returns {Beadnet}
	 */
	addNodes(nodes) {
		nodes.forEach((node) => this.addNode(node));

		/* make this function chainable */
		return this;
	}

	/**
	 * Removes a the node with the given id from the network.
	 *
	 * @param {String} nodeId
	 * @returns {Beadnet}
	 */
	removeNode(nodeId) {
		this._nodes = this._nodes.filter((node) => node.id !== nodeId);
		this._channels = this._channels.filter((channel) => channel.source.id !== nodeId && channel.target.id !== nodeId);

		this._updateNodes();
		this._updateChannels();

		/* make this function chainable */
		return this;
	};

	/**
	 * Create new nodes with random names.
	 *
	 * @param {Number} [count=1] - how many nodes.
	 * @returns {Node}
	 */
	createRandomNodes(count) {
		if ((typeof count !== "undefined" && typeof count !== "number") || count < 0) {
			throw new TypeError("parameter count must be a positive number");
		}
		return Array.from(new Array(count), (x) => {
			return {
				id: getName(),
				balance: getRandomNumber(100)
			};
		});
	}

	/**
	 * Picks and returns a random node from the list of existing nodes.
	 *
	 * @returns {Node}
	 */
	getRandomNode() {
		return this._nodes[getRandomNumber(this._nodes.length)];
	}

	/**
	 * Re-draw all channels.
	 *
	 * @private
	 * @returns {d3.selection} this._channelElements
	 */
	_updateChannels() {
		const opt = this._opt;

		/* update beads of each channel */
		this._channels = this._channels.map((ch) => {
			const balance = ch.sourceBalance + ch.targetBalance;
			let index = -1;
			ch.beads = [];
			ch.beads.push(...Array.from(new Array(ch.sourceBalance), (x) => {
				index++;
				return {
					state: 0,
					index: index,
					//id: `bead_${ch.id}_source_${index}x${ch.sourceBalance}`
					id: `bead_${ch.id}_source_${index}x${balance}`
				}
			}));
			ch.beads.push(...Array.from(new Array(ch.targetBalance), (x) => {
				index++;
				return {
					state: 1,
					index: index,
					//id: `bead_${ch.id}_target_${index}x${ch.targetBalance}`
					id: `bead_${ch.id}_target_${index}x${balance}`
				}
			}));
			return ch;
		});

		console.log("_updateChannels: ", this._channels);

		this._channelElements = this.channelContainer.selectAll(".channel").data(this._channels, (d) => d.id);

		/* remove channels that no longer exist */
		this._channelElements.exit()
			.transition()
			.duration(500)
			.style("opacity", 0)
			.remove();

		/* create new svg elements for new channels */
		let channelRoots = this._channelElements.enter().append("g")
			.attr("class", "channel");

		this._channelElements.merge(channelRoots)
			.attr("id", (d) => d.id)
			.attr("source-balance", (d) => d.sourceBalance)
			.attr("target-balance", (d) => d.targetBalance)
			.attr("source-id", (d) => d.source.id)
			.attr("target-id", (d) => d.target.id)
			.attr("highlighted", (d) => d.hightlighted);

		channelRoots
			.append("path")
			.attr("class", "path")
			.attr("id", (d) => `${d.id}_path`)
			.style("stroke-width", (d) => opt.channels.strokeWidth === "auto" ? (d.sourceBalance + d.targetBalance) * 2 : opt.channels.strokeWidth)
			.style("stroke", opt.channels.color)
			.style("fill", "none");

		if (this._opt.channels.showBalance) {
			channelRoots
				.append("text")
				.attr("class", "channel-text")
				.attr("font-family", "Verdana")
				.attr("font-size", "12")
				.attr("dx", 150) //TODO: place this dynamic between the beads on the path
				.attr("dy", -7)
				.style("pointer-events", "none")
				.append("textPath")
				.attr("xlink:href", (d) => `#${d.id}_path`)
				.attr("class", "channel-text-path")
				.style("stroke-width", 1)
				.style("stroke", opt.channels.color)
				.style("fill", "none")
				.text((d) => `${d.sourceBalance}:${d.targetBalance}`)
		}

		let beadsContainer = channelRoots.append("g")
			.attr("class", "beads")
			.attr("id", (d) => "beads_container");

		this.beadElements = beadsContainer.selectAll(".bead")
			.data((d) => d.beads, (d) => d.id);

		this.beadElements.exit()
			.transition()
			.duration(800)
			.style("opacity", 0)
			.remove();

		let beadElement = this.beadElements.enter().append("g")
			.attr("class", "bead");

		this.beadElements.merge(beadElement)
			.attr("channel-state", (d) => d.state) //TODO: 0 or 1?
			.attr("id", (d) => d.id)
			.attr("index", (d) => d.index);

		beadElement.append("circle")
			.attr("r", opt.beads.radius)
			.style("stroke-width", opt.beads.strokeWidth)
			.style("fill", opt.beads.color)
			.style("stroke", opt.beads.strokeColor);

		if (opt.beads.showIndex) {
			/* show bead index */
			beadElement.append("text")
				.attr("class", "bead-text")
				.attr("stroke", opt.container.backgroundColor)
				.attr("fill", opt.container.backgroundColor)
				.attr("font-family", "sans-serif")
				.attr("font-size", "8px")
				.attr("y", "2px")
				.attr("text-anchor", "middle")
				.attr("pointer-events", "none")
				.style("stroke-width", 0.2)
				.text((d) => d.index);
		}

		/* update channel */
		// this._channelElements
		// 	.attr("source-balance", (d) => d.sourceBalance)
		// 	.attr("target-balance", (d) => d.targetBalance)
		// 	.attr("source-id", (d) => d.source.id)
		// 	.attr("target-id", (d) => d.target.id)
		// 	.attr("highlighted", (d) => d.hightlighted);

		// this._channelElements.selectAll(".path")
		// 	.attr("id", (d) =>  `${d.id}_path`)
		// 	.style("stroke-width", opt.channels.strokeWidth)
		// 	.style("stroke", opt.channels.color)
		// 	.style("fill", "none");

		if (this._opt.channels.showBalance) {
			this._channelElements.selectAll(".channel-text-path")
				.text((d) => `${d.sourceBalance}:${d.targetBalance}`);
		}

		/***************************************************/
		/* update channel styles */
		this._channelElements.selectAll("[highlighted=true] .path")
			.style("stroke", opt.channels.colorHighlighted);

		this._channelElements.selectAll("[highlighted=false] .path")
			.style("stroke", opt.channels.color);
		/************************************************* */

		/* update this._paths; needed in this._ticked */
		this._paths = this.channelContainer.selectAll(".channel .path");
		this.beadElements = this.channelContainer.selectAll(".channel .beads .bead");

		this.simulation
			.force("link")
			.links(this._channels);

		this.simulation
			.alphaTarget(0)
			.restart();

		return this._channelElements;
	}

	/**
	 * Creates an unique channel ID using the source and target node IDs and the balances.
	 *
	 * @param {*} channelInfos
	 */
	_getUniqueChannelId(channelInfos) {
		const channelBalance = (+channelInfos.sourceBalance || 0) + (+channelInfos.targetBalance || 0);
		let nonce = 0;
		let id = `channel${channelInfos.source}${channelBalance}${channelInfos.target}${nonce > 0 ? nonce : ""}`;
		while (this._channels.filter((channel) => channel.id === id).length > 0) {
			nonce++;
			id = `channel${channelInfos.source}${channelBalance}${channelInfos.target}${nonce > 0 ? nonce : ""}`;
		}
		return id;
	}

	/**
	 * Adds a new channel.
	 *
	 * @param {Channel} channel
	 */
	addChannel(channel) {
		channel.sourceBalance = channel.sourceBalance || 0;
		channel.targetBalance = channel.targetBalance || 0;

		if (!channel.sourceBalance && !channel.targetBalance) {
			throw new Error("Its not possible to create a channel without any funds. Please add a sourceBalance and/or targetBalance.");
		}

		let source = this._getNodeById(channel.source);
		let target = this._getNodeById(channel.target);

		if (source.balance < channel.sourceBalance) {
			throw new Error("Insufficient Funds. The source node has not enough funds to open this channel");
		}
		if (target.balance < channel.targetBalance) {
			throw new Error("Insufficient Funds. The target node has not enough funds to open this channel");
		}

		/* update balance of the source and target nodes */
		source.balance -= channel.sourceBalance;
		target.balance -= channel.targetBalance;
		this._updateNodes();

		/* update the internal channel list */
		const id = this._getUniqueChannelId(channel);
		this._channels.push({
			id: id,
			hightlighted: false,
			source: source,
			target: target,
			sourceBalance: channel.sourceBalance,
			targetBalance: channel.targetBalance
		});
		this._updateChannels();

		return this;
	}

	/**
	 * Adds an array of channels.
	 *
	 * @param {*} channels
	 */
	addChannels(channels) {
		channels.forEach((channel) => this.addChannel(channel));
	}

	/**
	 * Create new nodes with random names.
	 *
	 * @param {Number} [count=1] - how many nodes.
	 * @param {Boolean} [unique=true] - should channels be unique?
	 * @returns {Node}
	 */
	createRandomChannels(count, unique = true) {
		// if ((typeof count !== "undefined" && typeof count !== "number") || count < 0) {
		// 	throw new TypeError("parameter count must be a positive number");
		// }
		return Array.from(new Array(count), (x) => {
			let source = this.getRandomNode();
			let target = this.getRandomNode();

			if (unique) {
				let killCounter = 0;
				while ((
						source.id === target.id ||
						(this.getChannels(source.id, target.id).length > 0) &&
						killCounter < this._channels.length)) {
					source = this.getRandomNode();
					target = this.getRandomNode();
					killCounter++;
				}
			}

			let sourceBalance = getRandomNumber(4);
			let targetBalance = getRandomNumber(4);
			sourceBalance = (!sourceBalance && !targetBalance) ? getRandomNumber(4) + 1 : sourceBalance;

			let channel = {
				source: source.id,
				target: target.id,
				sourceBalance: sourceBalance,
				targetBalance: targetBalance
			};
			channel.id = this._getUniqueChannelId(channel);
			return channel;
		});
	}

	/**
	 * Picks and returns a random channel from the list of existing channels.
	 */
	getRandomChannel() {
		return this._channels[getRandomNumber(this._channels.length)];
	}

	/**
	 * Returns the number of channels.
	 * @returns {Number} number of channels
	 */
	getChannelCount() {
		return this._channels.length;
	}

	/**
	 * Remove channel with the given source and target ids.
	 *
	 * @returns {Beadnet} beadnet
	 */
	removeChannel(sourceId, targetId) {
		this._channels = this._channels.filter((channel) => {
			if ((channel.source.id !== sourceId) || (channel.target.id !== targetId)) {
				return true;
			} else {
				let sourceNode = this._getNodeById(sourceId);
				sourceNode.balance += channel.sourceBalance;
				let targetNode = this._getNodeById(targetId);
				targetNode.balance += channel.targetBalance;
				return false;
			}
		});

		console.log("removeChannel: ", this._channels);
		this._updateNodes();
		this._updateChannels();

		return this;
	}

	/**
	 * Returns all channels that exist between two nodes.
	 *
	 * @param {String} sourceId
	 * @param {String} targetId
	 * @returns {Channel[]} channels
	 */
	getChannels(sourceId, targetId) {
		return this._channels.filter((channel) =>
			(channel.source.id === sourceId && channel.target.id === targetId) ||
			(channel.target.id === sourceId && channel.source.id === targetId)
		);
	}

	/**
	 * Transfer a amount from the source node banlance to or from the channel.
	 *
	 * @param {String} sourceId - source node id
	 * @param {String} targetId - target node id
	 * @param {Number} amount - positive if moved from not to channel; negative if moved from channel to node.
	 * @returns {Beadnet} beadnet
	 */
	changeChannelSourceBalance(sourceId, targetId, amount) {
		const channels = this.getChannels(sourceId, targetId);
		if (!channels || channels.length <= 0) {
			//TODO: throw an error
			console.error(`no channel found between "${sourceId}" and "${targetId}"`);
			return this;
		}
		//TODO: handle error if more than one channel is found.
		let channel = channels[0];
		let node = this._getNodeById(channel.source.id);
		//TODO: throw error if node not found;

		if (amount > 0) {
			amount = Math.abs(amount);
			if (node.balance < amount) {
				//TODO: throw an error
				console.error(`node ${sourceId} has not enough balance (${node.balance}) to refund the channel by ${amount}`);
				return this;
			}
			node.balance -= amount;
			channel.sourceBalance += amount;
		} else {
			amount = Math.abs(amount);
			if (channel.sourceBalance < amount) {
				//TODO: throw an error
				console.error(`sourceBalance (${sourceId}) is not enough (${channel.sourceBalance}) to remove an amount of ${amount}`);
				return this;
			}
			node.balance += amount;
			channel.sourceBalance -= amount;
		}

		this._updateNodes();
		this._updateChannels();

		return this;
	}

	/**
	 * Transfer a amount from the target node banlance to or from the channel.
	 *
	 * @param {String} sourceId - source node id
	 * @param {String} targetId - target node id
	 * @param {Number} amount - positive if moved from node to channel; negative if moved from channel to node.
	 * @returns {Beadnet} beadnet
	 */
	changeChannelTargetBalance(sourceId, targetId, amount) {
		const channels = this.getChannels(sourceId, targetId);
		if (!channels || channels.length <= 0) {
			//TODO: throw an error
			console.error(`no channel found between "${sourceId}" and "${targetId}"`);
			return this;
		}
		//TODO: handle error if more than one channel is found.
		let channel = channels[0];
		let node = this._getNodeById(channel.target.id);
		//TODO: throw error if node not found;

		if (amount > 0) {
			amount = Math.abs(amount);
			if (node.balance < amount) {
				//TODO: throw an error
				console.error(`node ${targetId} has not enough balance (${node.balance}) to refund the channel by ${amount}`);
				return this;
			}
			node.balance -= amount;
			channel.targetBalance += amount;
		} else {
			amount = Math.abs(amount);
			if (channel.targetBalance < amount) {
				//TODO: throw an error
				console.error(`targetBalance (${targetId}) is not enough (${channel.targetBalance}) to remove an amount of ${amount}`);
				return this;
			}
			node.balance += amount;
			channel.targetBalance -= amount;
		}

		this._updateNodes();
		this._updateChannels();

		return this;
	}

	/**
	 * Mark a channel as "highlighted".
	 *
	 * @param {String} sourceId
	 * @param {String} targetId
	 * @param {Boolean} state - should the channel be highlighted [true]/false
	 * @returns {Beadnet}
	 */
	highlightChannel(sourceId, targetId, state) {
		let channels = this.getChannels(sourceId, targetId);
		channels.forEach((channel) => channel.hightlighted = state ? state : !channel.hightlighted);

		this._updateChannels();

		/* make this function chainable */
		return this;
	}

	/**
	 * Calculate and then translate a bead to a certain position.
	 *
	 * @param {*} b
	 * @returns {string} bead position.
	 */
	_positionBeat(b, d) {
		const bead = d3.select(b);
		const index = d.index;
		const state = +bead.attr("channel-state"); // state 0=source, 1=target
		const channel = d3.select(bead.node().parentNode.parentNode);
		const path = channel.select(".path").node();

		const channelData = channel.data()[0];
		const sourceBalance = channelData.sourceBalance;
		const targetBalance = channelData.targetBalance;
		const balance = sourceBalance + targetBalance;
		const distanceBetweenBeads = this._opt.beads.distance + this._opt.beads.spacing;
		const channelPadding = this._opt.beads.firstPosition + this._opt.beads.spacing;

		let startPosition = channelPadding + (index * distanceBetweenBeads);
		let endPosition = channelPadding + ((balance - 1 - index) * distanceBetweenBeads);
		let totalDistance = path.getTotalLength() - startPosition - endPosition;

		const beadPosition = path.getPointAtLength(startPosition + state * totalDistance);
		return `translate(${beadPosition.x},${beadPosition.y})`;
	}

	/**
	 * Handle an animation tick.
	 *
	 * @private
	 */
	_ticked() {
		if (this._nodeElements) {
			this._nodeElements.attr("transform", (data) => `translate(${data.x},${data.y})`);
		}
		if (this._paths) {
			this._paths.attr("d", (d) => {
				// var count = this._channels.filter((c) => ((d.source.id === d.source.id) && (d.target.id === d.target.id))).length;
				// if (count <= 1) {
				return `M${d.source.x},${d.source.y} ${d.target.x},${d.target.y}`;
				// } else {
				// 	var dx = d.target.x - d.source.x;
				// 	var dy = d.target.y - d.source.y;
				// 	var dr = Math.sqrt((dx*dx+count) + (dy*dy+count));
				// 	return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`;
				// }
			});
		}
		this.tickedBeads();
	}

	/**
	 * Handle bead animation.
	 */
	tickedBeads() {
		let that = this;
		if (!this.beadElements || this.beadElements.length === 0 || this.beadElements.empty()) {
			return;
		}
		this.beadElements.attr("transform", function(d) {
			return that._positionBeat(this, d);
		});
	}

	/**
	 * Animates a bead movement.
	 *
	 * @param {*} bead
	 * @param {*} direction
	 * @param {*} delay
	 */
	animateBead(bead, direction, delay) {
		let that = this;
		direction = !!direction;
		const select = d3.select(bead);
		return select.transition()
			.delay(delay)
			//.ease(d3.easeLinear)
			.ease(d3.easeQuadInOut)
			.duration(1000)
			.attrTween("channel-state", function(a) {
				return function(t) {
					that.tickedBeads();
					if (direction) {
						return 1 - t;
					} else {
						return t
					}
				}
			});
	}

	/**
	 * Moves a certain amount of beads from source to target node. If a callback is provided, it is called after the animation
	 * has stopped.
	 *
	 * @param {*} sourceId
	 * @param {*} targetId
	 * @param {*} beadCount
	 * @param {*} callback
	 * @returns {Beadnet} beadnet
	 */
	moveBeads(sourceId, targetId, beadCount, callback) {
		const channels = this.getChannels(sourceId, targetId);

		let channel = channels[0];
		if (!channel) {
			//TODO: throw error!?
			console.error("no channel found!");
			return;
		}

		// TODO: get channel with source and target
		const channelElement = d3.select(`#${channel.id}`);

		if (channel.source.id === sourceId) {

			let sourceBalance = channel.sourceBalance;
			let targetBalance = channel.targetBalance;
			let startIndex = sourceBalance - beadCount;
			let endIndex = startIndex + beadCount - 1;

			let that = this;
			let transitionCounter = 0;
			channelElement.selectAll(".bead").each(function(d, index) {
				if (index >= startIndex && index <= endIndex) {
					const delay = (endIndex - index) * 100;
					transitionCounter++;
					that.animateBead(this, d.state, delay).on("end", (ch, a, b) => {
						sourceBalance--;
						targetBalance++;
						d.state = 1;

						channel.sourceBalance = sourceBalance;
						channel.targetBalance = targetBalance;
						that._updateChannels();

						channelElement
							.attr("source-balance", sourceBalance)
							.attr("target-balance", targetBalance);

						if (that._opt.channels.showBalance) {
							channelElement.select(".channel-text-path")
								.text(`${sourceBalance}:${targetBalance}`);
						}

						transitionCounter--;
						if (transitionCounter <= 0) {
							return callback && callback();
						}
					});
				}
			});

		} else {

			let sourceBalance = channel.sourceBalance;
			let targetBalance = channel.targetBalance;
			let startIndex = (sourceBalance + targetBalance) - targetBalance;
			let endIndex = startIndex + beadCount - 1;

			let that = this;
			let transitionCounter = 0;
			channelElement.selectAll(".bead").each(function(d, index) {
				if (index >= startIndex && index <= endIndex) {
					const delay = (index) * 100;
					transitionCounter++;
					that.animateBead(this, d.state, delay).on("end", (ch, a, b) => {
						sourceBalance++;
						targetBalance--;
						d.state = 0;

						channel.sourceBalance = sourceBalance;
						channel.targetBalance = targetBalance;
						that._updateChannels();

						channelElement
							.attr("source-balance", sourceBalance)
							.attr("target-balance", targetBalance);

						if (that._opt.channels.showBalance) {
							channelElement.select(".channel-text-path")
								.text(`${sourceBalance}:${targetBalance}`);
						}

						transitionCounter--;
						if (transitionCounter <= 0) {
							return callback && callback();
						}
					});
				}
			});

		}

		return this;
	}

	/**
	 * Handles the start of mouse drag event.
	 *
	 * @private
	 */
	_onDragStart(d) {
		if (!d3.event.active) {
			this.simulation
				.alphaTarget(0.1)
				.restart();
		}
		d.fx = d.x;
		d.fy = d.y;
	}

	/**
	 * Handles the mouse drag event.
	 *
	 * @private
	 */
	_onDragged(d) {
		d.fx = d3.event.x;
		d.fy = d3.event.y;
	}

	/**
	 * Handles the end of mouse drag event.
	 *
	 * @private
	 */
	_onDragendEnd(d) {
		if (!d3.event.active) {
			this.simulation
				.alphaTarget(0);
		}
		d.fx = null;
		d.fy = null;
	}

	/**
	 * Initialize presentation mode, check if the provided steps are valid.
	 *
	 * @private
	 */
	_initializePresentation() {
		if (this._opt.presentation && this._opt.presentation.steps && this._opt.presentation.steps.length > 0) {
			this.presentation = {
				currentState: 0,
				steps: this._opt.presentation.steps,
			};
			this.presentation.steps.forEach(step => {
				step.forEach(subStep => {
					let fnMapping = COMMAND_MAPPING[subStep.cmd];
					if (fnMapping) {
						if (!subStep.args || subStep.args.length === 0 || subStep.args.length !== fnMapping.numArgs) {
							console.error("the command " + subStep.cmd + " requires exactly " + fnMapping.numArgs + " arguments!")
						}
					} else {
						console.error("invalid command " + subStep.cmd + "!");
					}
				})
			});
		} else {
			console.error("presentation must be an object that contains one or more steps");
		}
	}

	/**
	 * Show the next step of the presentation. Only has an effect if the instance was initialized in presentation mode.
	 */
	nextStep() {
		if (!this.presentation) {
			console.log("not in presentation mode! please pass a presentation object when creating the beadnet.");
			return;
		}
		if (this.presentation.currentState >= this.presentation.steps.length) {
			console.log("presentation reached its end. please restart it.");
			return;
		}
		let script = this.presentation.steps[this.presentation.currentState];
		let bn = this;
		script.forEach(subStep => {
			let fnMapping = COMMAND_MAPPING[subStep.cmd];
			let fn = bn[fnMapping.fnName];
			fn.apply(bn, subStep.args);
		});
		this.presentation.currentState++;
	}
}

export default Beadnet;