Files
GitList/web/js/networkGraph.js

333 lines
10 KiB
JavaScript

/**
* 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( $('table.network-graph').length !== 1 ) {
return;
}
var
cfg = {
laneColors: ['#ff0000', '#0000FF', '#00FFFF', '#00FF00', '#FFFF00', '#ff00ff'],
laneWidth: 10,
dotRadius: 3,
rowHeight: 42 // thanks for making this divisable by two
},
// the table element into which we will render our graph
commitsTable = $('table.network-graph').first(),
nextPage = commitsTable.data('source'),
refreshButton = $('<button class="btn btn-small"></button>').insertAfter(commitsTable.parent('div'))
;
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);
};
var refreshButtonClickHandler = function () {
fetchCommitData(nextPage);
};
refreshButton.click(refreshButtonClickHandler);
// load initial data
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];
console.log('taking commit out', commit.hash, parentsBeingWaitedFor);
}
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 ) {
console.log('Freeing lane ', thisChild.lane.number);
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.
console.log('Occupying lane ', commit.lane.number);
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 ) {
$.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 renderCommit( commit ) {
var tableRow = $('<tr></tr>');
// the required canvas width is at least the lane center plus the radius - but that will be added later
// now the parent with the highest lane number determines whether we need more space to the right...
// build the table row and insert it, so we can find out the required height
var drawingArea = $('<div class="network-tree-segment"></div>');
tableRow.append( $('<td/>').append(drawingArea));
tableRow.append('<td>' + commit.date.toLocaleString() +'</td>');
tableRow.append('<td>' + commit.author.name + '</td>');
tableRow.append('<td>' + commit.message + '</td>');
tableRow.data('theCommit', commit);
commitsTable.append(tableRow);
// awesome, now we have the height!
var paper = Raphael( drawingArea[0], cfg.laneWidth * maxLanes, cfg.rowHeight);
tableRow.data('rjsPaper', paper);
commit.tr = tableRow;
commit.dot = paper.circle( commit.lane.centerX, cfg.rowHeight/2, cfg.dotRadius );
commit.dot.attr({
fill: commit.lane.color,
stroke: 'none'
});
// render the line from this commit to it's children, but on their lane
$.each( commit.children, function ( idx, thisChild ) {
connectDots( commit, thisChild );
});
}
function connectDots( firstCommit, secondCommit ) {
var tableRow = firstCommit.tr;
// for each child,
// move upwards in <tr>s, beginning from the "commit" (not: child!)
// until the tr.data('theCommit') is thisChild.
//
// if there is one child only, stay on the commit's lane as long as possible,
// 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
// "forking" from a line
var nRow = tableRow.prev('tr'),
// lineX holds the X position the line will be on while it's straight
lineLane = firstCommit.lane;
if( firstCommit.isFork ) {
lineLane = secondCommit.lane;
}
// before iterating upwards as described above, the line part from the commit to the adequate lane
// must be drawn
tableRow.data('rjsPaper').path(
getSvgLineString( firstCommit.lane.centerX, cfg.rowHeight/2,
lineLane.centerX, 0) )
.attr({
stroke: lineLane.color, "stroke-width": 2
})
.data('theCommit', firstCommit).data('theChild', secondCommit).click(lineClickHandler)
.toBack();
while( nRow.length > 0 ) {
if ( nRow.data('theCommit') === secondCommit ) {
// we are done, render only the bottom half line towards the child
// Starting at lineX, but moving to thisChild's lane.
nRow.data('rjsPaper')
.path(
getSvgLineString( lineLane.centerX, cfg.rowHeight,
secondCommit.lane.centerX, cfg.rowHeight/2) )
.attr({
stroke: lineLane.color, "stroke-width": 2
})
.data('theCommit', firstCommit).data('theChild', secondCommit).click(lineClickHandler)
.toBack();
return;
} else {
// this is just a common "throughput" line part from bottom of the TR to top without any X movement
//
// maybe the paper isn't big enough yet, so expand it first...
nRow.data('rjsPaper')
.path(
getSvgLineString( lineLane.centerX, 0,
lineLane.centerX, cfg.rowHeight) )
.attr({
stroke: lineLane.color, "stroke-width": 2
})
.data('theCommit', firstCommit).data('theChild', secondCommit).click(lineClickHandler)
.toBack();
nRow = nRow.prev('tr');
}
}
}
function getSvgLineString( fromX, fromY, toX, toY ) {
return 'M' + fromX + ',' + fromY + 'L' + toX + ',' + toY;
}
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,
'centerX': ( laneNumber * cfg.laneWidth ) + (cfg.laneWidth/2),
'color': cfg.laneColors[ laneNumber % cfg.laneColors.length ]
};
}
});