RSS Git Download  Clone
Raw Blame History
/**
 * Network Graph JS
 * This File is a part of the GitList Project at http://gitlist.org
 *
 * @license http://www.opensource.org/licenses/bsd-license.php
 * @author Lukas Domnick <lukx@lukx.de> http://github.com/lukx
 */
$( function() {

	// initialise network graph only when there is one network graph container on the page
	if( $('div.network-graph').length !== 1 ) {
		return;
	}

	var

	cfg = {
		laneColors: ['#ff0000', '#0000FF', '#00FFFF', '#00FF00', '#FFFF00', '#ff00ff'],
		laneHeight: 20,
		columnWidth: 42,
		dotRadius: 3
	},

	// the table element into which we will render our graph
	commitsGraph = $('div.network-graph').first(),
	nextPage = commitsGraph.data('source'),

	refreshButton = $('<button class="btn btn-small"></button>').insertAfter(commitsGraph.parent('div')),
	paper = Raphael( commitsGraph[0], commitsGraph.width(), commitsGraph.height()),
	usedColumns = 0
	;

	window.pap = paper;

	function fetchCommitData( url ) {
		console.log('Starting to fetch commit data from ', url);
		setRefreshButtonState(true);
		$.ajax({
			dataType: "json",
			url: url,
			success: handleNetworkDataLoaded,
			error: handleNetworkDataError
		});
	}


	function setRefreshButtonState( isCurrentlyLoading ) {
		var newInner = '<i class="icon-repeat"></i> Load more';
		if( isCurrentlyLoading ) {
			newInner = '<i class="icon-refresh"></i> Loading...';
		}

		refreshButton.html(newInner);
	};

	function refreshButtonClickHandler() {
		fetchCommitData(nextPage);
	};


	function handleNetworkDataLoaded( data ) {
		setRefreshButtonState(false);
		console.log('Retreived Commit Data', data);

		// store the next page as gotten from pagination
		nextPage = data.nextPage;

		// no commits or empty commits array? Well, we can't draw a graph of that
		if( !data.commits || data.commits.length < 1 ) {
			handleNoAvailableData();
			return;
		}

		prepareCommits( data.commits );
		renderCommits( data.commits );
	}

	function handleNetworkDataError( err ){
		setRefreshButtonState(false);
		console.log(err);
	}

	function handleNoAvailableData() {
		console.log('No Data available');
	}

	var parentsBeingWaitedFor = {},
		occupiedLanes = [],
		maxLanes = 0;

	function prepareCommits( commits ) {
		$.each( commits, function ( index, commit) {
			prepareCommit( commit );
		});
	}

	function findFreeLane() {
		var i = 0;

		while( true ) {
			// if an array index is not yet defined or set to false, the lane with that number is free.
			if( !occupiedLanes[i] ) {
				return i;
			}
			i ++;
		}
	}

	function prepareCommit( commit ) {
		// make "date" an actual JS Date object
		commit.date = new Date(commit.date*1000);

		// the parents will be filled once they have become rendered

		commit.parents = [];
		// get children for this commit
		commit.children = [];
		if( parentsBeingWaitedFor.hasOwnProperty( commit.hash )) {
			// there are child commits waiting
			commit.children = parentsBeingWaitedFor[commit.hash];

			// let the children know their parent objects
			$.each( commit.children, function(key, thisChild ) {
				thisChild.parents.push( commit );
			});

			// remove this item from parentsBeingWaitedFor
			delete parentsBeingWaitedFor[commit.hash];
		}

		commit.isFork  = ( commit.children.length > 1 );
		commit.isMerge = ( commit.parentsHash.length > 1 );

		// after a fork, the occupied lanes must be cleaned up. The children used some lanes we no longer occupy
		if (commit.isFork === true ) {
			$.each( commit.children, function( key, thisChild ) {
				// free this lane
				occupiedLanes[thisChild.lane.number] = false;
			});
		}

		// find out which lane we're on. Start with a free one
		var laneNumber = findFreeLane();

		// if the child is a merge, we need to figure out which lane we may render this commit on.
		// Rules are simple: A "parent" by the same author as the merge may render on the same line as the parent
		// others take the next free lane.
		if( commit.children.length > 0) {
			if( commit.children[0].isMerge && commit.children[0].author.email === commit.author.email ) {
				console.log('same author, same lane', commit);
				laneNumber = commit.children[0].lane.number;
				// furthermore, commits in a linear line of events may stay on the same lane, too
			} else if ( !commit.children[0].isMerge ) {
				console.log('Taking the childs lane because it was not a merge', commit);
				laneNumber = commit.children[0].lane.number;
			}
		}

		commit.lane = getLaneInfo( laneNumber );

		// now the lane we chose must be marked occupied again.
		occupiedLanes[commit.lane.number] = true;
		maxLanes = Math.max( occupiedLanes.length, maxLanes);

		// This commit's parents are not on stage yet, as we are rendering following the time line.
		// Therefore we are registering this commit as "waiting" for each of the parent hashes

		$.each( commit.parentsHash, function( key, thisParentHash ) {
			// iterating over the rendered commit's parent hashes...
			// parent hash should always be a string, but although I can't imagine a reason why it shouldn't,
			// let's just clear out the case where it is a complete commit object...

			// If parentsBeingWaitedFor does not already have a key for thisParent's hash, initialise as array
			if( !parentsBeingWaitedFor.hasOwnProperty(thisParentHash) ) {
				parentsBeingWaitedFor[thisParentHash] = [];
			}

			// allright, now register the commit that is currently being rendered with the parent queue
			parentsBeingWaitedFor[ thisParentHash ].push( commit );
		});

	}

	var lastRenderedDate = new Date(0);
	function renderCommits( commits ) {
		var neededWidth = ((usedColumns + Object.keys(commits).length) * cfg.columnWidth);
		if (  neededWidth > paper.width ) {
			console.log(neededWidth);
			extendPaper( neededWidth, paper.height  );


		} else {
			console.log( paper.width, neededWidth);
		}

		$.each( commits, function ( index, commit) {
			if( lastRenderedDate.getYear() !== commit.date.getYear()
				|| lastRenderedDate.getMonth() !== commit.date.getMonth()
				|| lastRenderedDate.getDate() !== commit.date.getDate() ) {
				// insert date row
			}
			renderCommit(commit);
			lastRenderedDate = commit.date;
		});
	}

	function extendPaper( newWidth, newHeight ) {
		var deltaX = newWidth - paper.width;

		paper.setSize( newWidth, newHeight );
		// fixup parent's scroll position
		paper.canvas.parentNode.scrollLeft = paper.canvas.parentNode.scrollLeft + deltaX;

		// now fixup the x position

		paper.forEach( function( el ) {

			if( el.type === "circle" ) {
				el.attr('cx', el.attr('cx') + deltaX);
			} else if ( el.type === "path") {
				var newXTranslation = el.data('currentXTranslation') || 0;
				newXTranslation += deltaX;
				el.transform( 't' + newXTranslation + ' 0' );
				el.data('currentXTranslation', newXTranslation);
			}
		});
	}

	function renderCommit( commit ) {
		// find the column this dot is drawn on
		usedColumns++;
		commit.column = usedColumns;

		// now the parent with the highest lane number determines whether we need more space to the right...


		commit.dot = paper.circle( getXPositionForColumnNumber(commit.column), commit.lane.centerY, cfg.dotRadius );
		commit.dot.attr({
			fill: commit.lane.color,
			stroke: 'none',
			cursor: 'pointer'
		})
			.data('commit', commit)
			.click( dotClickHandler );

		$.each( commit.children, function ( idx, thisChild ) {
			// if there is one child only, stay on the commit's lane as long as possible when connecting the dots.
			// but if there is more than one child, switch to the child's lane ASAP.
			// this is to display merges and forks where they happen (ie. at a commit node/ a dot), rather than
			// connecting from a line.
			// So: commit.isFork decides whether or not we must switch lanes early

			connectDots( commit, thisChild, commit.isFork );
		});
	}

	/**
	 *
	 * @param firstCommit
	 * @param secondCommit
	 * @param switchLanesEarly (boolean): Move the line to the secondCommit's lane ASAP? Defaults to false
	 */
	function connectDots( firstCommit, secondCommit, switchLanesEarly ) {
		switchLanesEarly = switchLanesEarly || false;


		var lineLane = switchLanesEarly ? secondCommit.lane : firstCommit.lane;

		// the connection has 3 segments:
		// - from the x/y center of firstCommit.dot to the rightmost end (x) of the commit's column, with y=lineLane
		// - from the rightmost end of firstCommit's column, to the leftmost end of secondCommit's column
		// - from the leftmost end of secondCommit's column (y=lineLane) to the x/y center of secondCommit



		// draw the line between the two dots
		paper.path( getSvgLineString( [firstCommit.dot.attr('cx'),
									   firstCommit.dot.attr('cy')],

									  [firstCommit.dot.attr('cx') + (cfg.columnWidth/2),
									   lineLane.centerY],

									[secondCommit.dot.attr('cx') - (cfg.columnWidth/2),
									   lineLane.centerY],

									  [secondCommit.dot.attr('cx'),
									   secondCommit.dot.attr('cy')]
			)).attr({
				stroke: lineLane.color, "stroke-width": 2
			}).toBack();
		return;
	}

	// set together a path string from any amount of arguments
	// each argument is an array of [x, y]
	function getSvgLineString( ) {
		if (arguments.length < 2) return

		// we are using a little trick here: Due to the right-to-left direction of the graph, the fix point is at the
		// right hand side. But the top-right point will change each time we extend the drawing area, which would
		// result in a terrible parsing and re-assembling every single sub path.
		// Instead, we use the moveto feature to start the line at "our" base (top-right), and draw the lines using
		// relative linetos: The linetos will always stay the same - we only have to update the base

		var svgString = 'M' + arguments[0][0] + ' ' + arguments[0][1];

		for (var i = 1, j = arguments.length; i < j; i++){

			// x =0 means a relatively unchanged x value

			svgString += 'L' + arguments[i][0] + ' ' + arguments[i][1];

		}

		return svgString;
	}

	function lineClickHandler() {
		console.log('Hi, I am connecting', this.data('theCommit'), 'with', this.data('theChild'));

		flashDot( this.data('theCommit').dot );
		flashDot( this.data('theChild').dot );
	}

	function flashDot( dot ) {
		var origCol = dot.attr('fill');
		dot.attr('fill', '#00FF00');
		dot.animate( {
			'fill': origCol
		}, 1000);
	}

	function dotClickHandler(evt) {
		console.log(this.data('commit'));
	}

	function getLaneInfo( laneNumber ) {
		return {
			'number': laneNumber,
			'centerY': ( laneNumber * cfg.laneHeight ) + (cfg.laneHeight/2),
			'color': cfg.laneColors[ laneNumber % cfg.laneColors.length ]
		};
	}

	function getXPositionForColumnNumber( columnNumber ) {
		// we want the column's center point
		return ( paper.width - ( columnNumber * cfg.columnWidth ) - (cfg.columnWidth / 2 ));
	}

	function initScrolling() {
		commitsGraph.on('mousedown', handleMouseDown);
		var lastX, lastY;

		function handleMouseDown( evt ) {
			commitsGraph.on('mousemove', handleMouseMove);
			commitsGraph.on('mouseup', handleMouseUp);
			commitsGraph.on('mouseleave', handleMouseUp);
			lastX = evt.pageX;
			lastY = evt.pageY;


		}

		function handleMouseMove(evt) {
			evt.preventDefault();

			commitsGraph[0].scrollLeft = commitsGraph[0].scrollLeft + lastX - evt.pageX;
			commitsGraph[0].scrollTop = commitsGraph[0].scrollTop + lastY - evt.pageY;

			lastX = evt.pageX;
			lastY = evt.pageY;
		}

		function handleMouseUp(evt) {
			commitsGraph.off('mousemove', handleMouseMove);
			commitsGraph.off('mouseup', handleMouseUp);
			commitsGraph.off('mouseleave', handleMouseUp);
		}
	}


	refreshButton.click(refreshButtonClickHandler);
	initScrolling();
	// load initial data
	fetchCommitData( nextPage );


});