/** * Network Graph JS * This File is a part of the GitList Project at https://github.com/patrikx3/gitlist * * @license https://github.com/patrikx3/gitlist/blob/master/LICENSE * @author Lukas Domnick <lukx@lukx.de> http://github.com/lukx * @author Patrik Laszlo <alabard@gmail.com> https://github.com/patrikx3/gitlist */ // global config const cfg = { get laneColors() { if (window.gitlist.isDark()) { return ['#BDBDBD', '#BBDEFB', '#03A9F4', '#2196F3', '#BDBDBD', '#FFFFFF']; } else { return ['#455A64', '#607D8B', '#757575', '#9E9E9E', '#CFD8DC', '#BDBDBD']; } }, get dotColor() { if (window.gitlist.isDark()) { return '#ffffff88'; } else { return '#00000088'; } }, laneHeight: 20, columnWidth: 42, dotRadius: 8 }; Object.defineProperty(window.gitlist, 'canvasLaneColors', { get: () => { return cfg.laneColors; } }) Object.defineProperty(window.gitlist, 'canvasDotColor', { get: () => { return cfg.dotColor; } }) let nextLaneIndex = 0 window.gitlist.randomCanvasLaneColors = () => { const items = window.gitlist.canvasLaneColors; nextLaneIndex++ if (nextLaneIndex > items.length) { nextLaneIndex = 0 } return items[nextLaneIndex]; } const $ = require('jquery') const phpDate = require('php-date') /** * DragScrollr is a custom made x/y-Drag Scroll Plugin for Gitlist * * TODO: Make this touch-scrollable */ $.fn.dragScrollr = function () { let lastX, lastY, hotZone = 50, container = this.first(), domElement = container[0]; // so basically container without the jQuery stuff function handleMouseDown(evt) { container.on('mousemove', handleMouseMove); container.on('mouseup', handleMouseUp); container.on('mouseleave', handleMouseUp); lastX = evt.pageX; lastY = evt.pageY; } function handleMouseMove(evt) { evt.preventDefault(); // save the last scroll position to figure out whether the scroll event has entered the hot zone const lastScrollLeft = domElement.scrollLeft; domElement.scrollLeft = domElement.scrollLeft + lastX - evt.pageX; domElement.scrollTop = domElement.scrollTop + lastY - evt.pageY; //console.log(lastScrollLeft, hotZone, domElement) // WARNING: hotZone !!!! if (lastScrollLeft > hotZone && domElement.scrollLeft <= hotZone) { container.trigger('enterHotZone'); } // when we move into the hot zone lastX = evt.pageX; lastY = evt.pageY; } function handleMouseUp(evt) { container.off('mousemove', handleMouseMove) .off('mouseup', handleMouseUp) .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() { const that = {}, occupiedLanes = []; // "private" methods function findLaneNumberFor(commit) { if (commit.lane) { // oh? we've already got a lane? return commit.lane.number; } // find out which lane may draw our dot on. Start with a free one let 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() { let 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; const 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 = $('<div></div>').appendTo(el), metaDisplay = $('<div></div>').appendTo(el), authorDisplay = $('<a rel="author"></a>').appendTo(metaDisplay), dateDisplay = $('<span ></span>').appendTo(metaDisplay); 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 phpDate(gitlist.dateFormat, date) } /** * 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) { el.appendTo(where); return that; }; that.positionTo = function (x, y) { el.css('left', x + 'px'); el.css('top', y + 'px'); }; that.outerWidth = function () { return el.outerWidth.apply(el, arguments); }; return that; } function commitDataRetriever(startPage, callback) { let that = {}, nextPage = startPage; let indicatorElements; global.isLoading = false; that.updateIndicators = function () { if (global.isLoading) { } else { } }; that.bindIndicator = function (el) { if (!indicatorElements) { indicatorElements = $(el); } else { indicatorElements = indicatorElements.add(el); } }; that.unbindIndicator = function (el) { indicatorElements.not(el); }; function handleNetworkDataLoaded(data) { global.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; } // WARNING: isLoading !!!! global.isLoading = true; that.updateIndicators(); $.ajax({ dataType: "json", url: nextPage, success: handleNetworkDataLoaded, error: handleNetworkDataError }); }; that.hasMore = function () { return (!!nextPage); }; return that; } window.gitlist.networkRedraw = () => { // initialise network graph only when there is one network graph container on the page if ($('div.network-graph').length !== 1) { return; } // the element into which we will render our graph let commitsGraph = $('div.network-graph').first(); commitsGraph.find('svg').remove(); commitsGraph.find('.network-commit-overlay').remove(); let laneManager = graphLaneManager() let dataRetriever = commitDataRetriever(commitsGraph.data('source'), handleCommitsRetrieved) let paper = Raphael(commitsGraph[0], commitsGraph.width(), commitsGraph.height()) let usedColumns = 0 let detailOverlay = commitDetailOverlay() dataRetriever.bindIndicator(commitsGraph.parent('.network-view')); detailOverlay.appendTo(commitsGraph); function handleEnterHotZone() { 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() { window.console && console.log('No (more) Data available'); } const 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) { let 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; } const lastRenderedDate = new Date(0); function renderCommits(commits) { let neededWidth = ((usedColumns + Object.keys(commits).length) * cfg.columnWidth); if (neededWidth > paper.width) { extendPaper(neededWidth, paper.height); } else if (dataRetriever.hasMore()) { // this is the case when we have not loaded enough commits to fill the paper yet. Get some more then... dataRetriever.retrieve(); } $.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) .click(handleCommitClick); // maybe we have not enough space for the lane yet if (commit.lane.centerY + cfg.laneHeight > paper.height) { extendPaper(paper.width, commit.lane.centerY + cfg.laneHeight) } $.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; const 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(); } // 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; let svgString = 'M' + arguments[0][0] + ' ' + arguments[0][1]; for (let 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(); let xPos = evt.pageX - commitsGraph.offset().left + commitsGraph.scrollLeft() - (detailOverlay.outerWidth() / 2); // check that x doesn't run out the viewport xPos = Math.max(xPos, commitsGraph.scrollLeft() + 10); xPos = Math.min(xPos, commitsGraph.scrollLeft() + commitsGraph.width() - detailOverlay.outerWidth() - 10); detailOverlay.positionTo(xPos, evt.pageY - commitsGraph.offset().top + commitsGraph.scrollTop() + 10); } function handleCommitMouseout(evt) { detailOverlay.hide(); } function handleCommitClick(evt) { location.href = this.data('commit').details; } function getXPositionForColumnNumber(columnNumber) { // we want the column's center point return (paper.width - (columnNumber * cfg.columnWidth) + (cfg.columnWidth / 2)); } function extendPaper(newWidth, newHeight) { const deltaX = newWidth - paper.width; paper.setSize(newWidth, newHeight); // fixup parent's scroll position try { const parent = document.getElementsByClassName('network-graph')[0] parent.scrollLeft = parent.scrollLeft + deltaX; } catch (e) { console.error(e) } // 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") { let newXTranslation = el.data('currentXTranslation') || 0; newXTranslation += deltaX; el.transform('t' + newXTranslation + ' 0'); el.data('currentXTranslation', newXTranslation); } }); } commitsGraph.dragScrollr(); commitsGraph.on('enterHotZone', handleEnterHotZone); // load initial data dataRetriever.retrieve(); }