RSS Git Download  Clone
Raw Blame History
/**
 * 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 = 200,
        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;

        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) {
            $(indicatorElements).addClass('loading-commits');
        } else {
            $(indicatorElements).removeClass('loading-commits');
        }
    };

    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;
        }

        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 {
            paper.canvas.parentNode.scrollLeft = paper.canvas.parentNode.scrollLeft + deltaX;
        } catch (e) {
            console.warn(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();

}