Finishing the Overlay

This commit is contained in:
Lukas Domnick
2013-05-17 23:14:05 +02:00
parent 7fb0016acc
commit 71f4e47ccf
4 changed files with 553 additions and 355 deletions

View File

@@ -5,384 +5,552 @@
* @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;
}
( function( $ ){
// global config
var
cfg = {
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 );
});
}
// Define the jQuery Plugins
/**
* DragScrollr is a custom made x/y-Drag Scroll Plugin for Gitlist
*
* @param firstCommit
* @param secondCommit
* @param switchLanesEarly (boolean): Move the line to the secondCommit's lane ASAP? Defaults to false
* TODO: Make this touch-scrollable
*/
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;
$.fn.dragScrollr = function() {
var lastX,
lastY,
container = this.first(),
domElement = container[0]; // so basically container without the jQuery stuff
function handleMouseDown( evt ) {
commitsGraph.on('mousemove', handleMouseMove);
commitsGraph.on('mouseup', handleMouseUp);
commitsGraph.on('mouseleave', handleMouseUp);
container.on('mousemove', handleMouseMove);
container.on('mouseup', handleMouseUp);
container.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;
domElement.scrollLeft = domElement.scrollLeft + lastX - evt.pageX;
domElement.scrollTop = domElement.scrollTop + lastY - evt.pageY;
// unify scroll event
container.trigger('dragscroll');
lastX = evt.pageX;
lastY = evt.pageY;
}
function handleMouseUp(evt) {
commitsGraph.off('mousemove', handleMouseMove);
commitsGraph.off('mouseup', handleMouseUp);
commitsGraph.off('mouseleave', handleMouseUp);
container.off('mousemove', handleMouseMove);
container.off('mouseup', handleMouseUp);
container.off('mouseleave', handleMouseUp);
}
// now bind the initial event
container.on('mousedown', handleMouseDown);
// return this instead of container, because of the .first() we applied - remember?
return this;
};
function graphLaneManager() {
var that = {},
occupiedLanes = [];
// "private" methods
function findLaneNumberFor( commit ) {
// oh? we've already got a lane?
if( commit.lane ) {
return commit.lane.number;
}
// find out which lane may draw our dot 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 child
// others take the next free lane.
// furthermore, commits in a linear line of events may stay on the same lane, too
if( commit.children.length > 0) {
if( !commit.children[0].isMerge // linear ...
|| ( commit.children[0].isMerge && commit.children[0].author.email === commit.author.email ) // same author
) {
laneNumber = commit.children[0].lane.number;
}
}
return laneNumber;
}
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 ++;
}
}
that.occupy = function( lane ) {
// make sure we work with lane numbers here
if( typeof lane === 'object' ) {
lane = lane.number;
}
occupiedLanes[lane] = true;
};
that.free = function( lane ) {
// make sure we work with lane numbers here
if( typeof lane === 'object' ) {
lane = lane.number;
}
occupiedLanes[lane] = false;
};
that.getLaneForCommit = function( commit ) {
// does this commit have a lane already?
if( commit.lane ) return commit.lane;
var laneNumber = findLaneNumberFor( commit );
return that.getLane( laneNumber );
};
that.getLane = function(laneNumber) {
return {
'number': laneNumber,
'centerY': ( laneNumber * cfg.laneHeight ) + (cfg.laneHeight/2),
'color': cfg.laneColors[ laneNumber % cfg.laneColors.length ]
};
};
return that;
}
function commitDetailOverlay( ) {
var that = {},
el = $('<div class="network-commit-overlay"></div>'),
imageDisplay = $('<img/>').appendTo(el),
messageDisplay = $('<h4></h4>').appendTo(el),
metaDisplay = $('<p></p>').appendTo(el),
authorDisplay = $('<a rel="author"></a>').appendTo(metaDisplay),
dateDisplay = $('<span></span>').appendTo(metaDisplay),
commit;
el.hide();
/**
* Pads an input number with one leading '0' if needed, and assure it's a string
*
* @param input Number
* @returns String
*/
function twoDigits( input ) {
if( input < 10 ) {
return '0' + input;
}
return '' + input;
}
/**
* Transform a JS Native Date Object to a string, maintaining the same format given in the commit_list view
* 'd/m/Y \\a\\t H:i:s'
*
* @param date Date
* @returns String
*/
function getDateString( date ) {
return twoDigits( date.getDate() ) + '/'
+ twoDigits( date.getMonth() ) + '/'
+ date.getFullYear() + ' at '
+ twoDigits(date.getHours()) + ':'
+ twoDigits(date.getMinutes()) + ':'
+ twoDigits(date.getSeconds());
}
/**
* update the author view
*
* @param author
*/
function setAuthor( author ) {
authorDisplay.html(author.name)
.attr('href', 'mailto:' + author.email );
imageDisplay.attr('src', author.image );
}
/**
* Set the commit that is being displayed in this detail overlay instance
*
* @param commit
* @return that
*/
that.setCommit = function( commit ) {
setAuthor( commit.author );
dateDisplay.html( ' authored on ' + getDateString( commit.date ) );
messageDisplay.html( commit.message );
return that;
};
// expose some jquery functions
that.show = function() {
el.show();
return that;
};
that.hide = function() {
el.hide();
return that;
};
that.appendTo = function(where) {
console.log(where);
el.appendTo(where);
return that;
};
that.positionTo = function( x, y ) {
el.css('left', x + 'px');
el.css('top', y + 'px');
}
return that;
}
function commitDataRetriever( startPage, callback ) {
var that = {},
nextPage = startPage,
isLoading = false,
indicatorElements;
that.updateIndicators = function() {
if( isLoading ) {
console.log($(indicatorElements));
$(indicatorElements).addClass('loading-commits');
} else {
console.log('not loading');
console.log($(indicatorElements));
$(indicatorElements).removeClass('loading-commits');
}
};
that.bindIndicator = function( el ) {
if( !indicatorElements ) {
indicatorElements = $(el);
} else {
indicatorElements.add(el);
}
};
that.unbindIndicator = function( el ) {
indicatorElements.not( el );
};
function handleNetworkDataLoaded(data) {
isLoading = false;
that.updateIndicators();
nextPage = data.nextPage;
if( !data.commits || data.commits.length === 0 ) {
callback( null );
}
callback(data.commits);
}
function handleNetworkDataError() {
throw "Network Data Error while retrieving Commits";
}
that.retrieve = function() {
if( !nextPage ) {
callback( null );
return;
}
isLoading = true;
that.updateIndicators();
$.ajax({
dataType: "json",
url: nextPage,
success: handleNetworkDataLoaded,
error: handleNetworkDataError
});
};
return that;
}
refreshButton.click(refreshButtonClickHandler);
initScrolling();
// load initial data
fetchCommitData( nextPage );
// the ('document').ready...
$( function() {
// initialise network graph only when there is one network graph container on the page
if( $('div.network-graph').length !== 1 ) {
return;
}
var
// the table element into which we will render our graph
commitsGraph = $('div.network-graph').first(),
laneManager = graphLaneManager(),
dataRetriever = commitDataRetriever( commitsGraph.data('source'), handleCommitsRetrieved ),
refreshButton = $('<button class="btn btn-small">Load More</button>').insertAfter(commitsGraph.parent('div')),
paper = Raphael( commitsGraph[0], commitsGraph.width(), commitsGraph.height()),
usedColumns = 0,
detailOverlay = commitDetailOverlay();
dataRetriever.bindIndicator( $('.network-header .meta') );
detailOverlay.appendTo( commitsGraph );
function refreshButtonClickHandler() {
dataRetriever.retrieve();
}
function handleCommitsRetrieved( commits ) {
// no commits or empty commits array? Well, we can't draw a graph of that
if( commits === null ) {
handleNoAvailableData();
return;
}
prepareCommits( commits );
renderCommits( commits );
}
function handleNoAvailableData() {
console.log('No Data available');
}
var awaitedParents = {};
function prepareCommits( commits ) {
$.each( commits, function ( index, commit) {
prepareCommit( commit );
});
}
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 prepared
commit.parents = [];
// we will want to store this commit's children
commit.children = getChildrenFor( commit );
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
laneManager.occupy( thisChild.lane );
});
}
commit.lane = laneManager.getLaneForCommit( commit );
// now the lane we chose must be marked occupied again.
laneManager.occupy( commit.lane );
registerAwaitedParentsFor( commit );
}
/**
* Add a new childCommit to the dictionary of awaited parents
*
* @param commit who is waiting?
*/
function registerAwaitedParentsFor( commit ) {
// This commit's parents are not yet known in our little world, 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 ) {
// If awaitedParents does not already have a key for thisParent's hash, initialise as array
if( !awaitedParents.hasOwnProperty(thisParentHash) ) {
awaitedParents[thisParentHash] = [ commit ];
} else {
awaitedParents[ thisParentHash ].push( commit );
}
});
}
function getChildrenFor( commit ) {
var children = [];
if( awaitedParents.hasOwnProperty( commit.hash )) {
// there are child commits waiting
children = awaitedParents[ commit.hash ];
// let the children know their parent objects
$.each( children, function(key, thisChild ) {
thisChild.parents.push( commit );
});
// remove this item from parentsBeingWaitedFor
delete awaitedParents[ commit.hash ];
}
return children;
}
var lastRenderedDate = new Date(0);
function renderCommits( commits ) {
var neededWidth = ((usedColumns + Object.keys(commits).length) * cfg.columnWidth);
if ( neededWidth > paper.width ) {
extendPaper( neededWidth, paper.height );
}
$.each( commits, function ( index, commit) {
if( lastRenderedDate.getYear() !== commit.date.getYear()
|| lastRenderedDate.getMonth() !== commit.date.getMonth()
|| lastRenderedDate.getDate() !== commit.date.getDate() ) {
// TODO: If desired, one could add a time scale on top, maybe.
}
renderCommit(commit);
});
}
function renderCommit( commit ) {
// find the column this dot is drawn on
usedColumns++;
commit.column = usedColumns;
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)
.mouseover( handleCommitMouseover )
.mouseout( handleCommitMouseout);
$.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 ) {
// default value for switchLanesEarly
switchLanesEarly = switchLanesEarly || false;
var lineLane = switchLanesEarly ? secondCommit.lane : firstCommit.lane;
// the connection has 4 stops, resulting in the following 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
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] within the paper's coordinate system
function getSvgLineString( ) {
if (arguments.length < 2) return;
var svgString = 'M' + arguments[0][0] + ' ' + arguments[0][1];
for (var i = 1, j = arguments.length; i < j; i++){
svgString += 'L' + arguments[i][0] + ' ' + arguments[i][1];
}
return svgString;
}
function handleCommitMouseover(evt) {
detailOverlay.setCommit( this.data('commit'))
.show()
.positionTo( evt.pageX - commitsGraph.position().left - 200,
evt.pageY - commitsGraph.position().top + 10);
}
function handleCommitMouseout(evt) {
detailOverlay.hide();
}
function getXPositionForColumnNumber( columnNumber ) {
// we want the column's center point
return ( paper.width - ( columnNumber * cfg.columnWidth ) + (cfg.columnWidth / 2 ));
}
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 positions of existing circles and lines
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);
}
});
}
refreshButton.click(refreshButtonClickHandler);
commitsGraph.dragScrollr();
// load initial data
dataRetriever.retrieve( );
});
});
}( jQuery ));