pcottle.learnGitBranching/src/js/visuals/index.js
2018-11-30 22:11:38 +07:00

953 lines
24 KiB
JavaScript

var _ = require('underscore');
var Q = require('q');
var intl = require('../intl');
var GRAPHICS = require('../util/constants').GRAPHICS;
var GlobalStateStore = require('../stores/GlobalStateStore');
var VisNode = require('../visuals/visNode').VisNode;
var VisBranch = require('../visuals/visBranch').VisBranch;
var VisBranchCollection = require('../visuals/visBranch').VisBranchCollection;
var VisTag = require('../visuals/visTag').VisTag;
var VisTagCollection = require('../visuals/visTag').VisTagCollection;
var VisEdge = require('../visuals/visEdge').VisEdge;
var VisEdgeCollection = require('../visuals/visEdge').VisEdgeCollection;
function GitVisuals(options) {
options = options || {};
this.options = options;
this.visualization = options.visualization;
this.commitCollection = options.commitCollection;
this.branchCollection = options.branchCollection;
this.tagCollection = options.tagCollection;
this.visNodeMap = {};
this.visEdgeCollection = new VisEdgeCollection();
this.visBranchCollection = new VisBranchCollection();
this.visTagCollection = new VisTagCollection();
this.commitMap = {};
this.rootCommit = null;
this.branchStackMap = null;
this.tagStackMap = null;
this.upstreamBranchSet = null;
this.upstreamTagSet = null;
this.upstreamHeadSet = null;
this.paper = options.paper;
this.gitReady = false;
this.branchCollection.on('add', this.addBranchFromEvent, this);
this.branchCollection.on('remove', this.removeBranch, this);
this.tagCollection.on('add', this.addTagFromEvent, this);
this.tagCollection.on('remove', this.removeTag, this);
this.deferred = [];
this.flipFraction = 0.65;
var Main = require('../app');
var that = this;
this._onRefreshTree = function() { that.refreshTree(); };
Main.getEvents().on('refreshTree', this._onRefreshTree, this);
}
GitVisuals.prototype.defer = function(action) {
this.deferred.push(action);
};
GitVisuals.prototype.deferFlush = function() {
_.each(this.deferred, function(action) {
action();
}, this);
this.deferred = [];
};
GitVisuals.prototype.resetAll = function() {
// make sure to copy these collections because we remove
// items in place and underscore is too dumb to detect length change
var edges = this.visEdgeCollection.toArray();
_.each(edges, function(visEdge) {
visEdge.remove();
}, this);
var branches = this.visBranchCollection.toArray();
_.each(branches, function(visBranch) {
visBranch.remove();
}, this);
var tags = this.visTagCollection.toArray();
_.each(tags, function(visTag) {
visTag.remove();
}, this);
_.each(this.visNodeMap, function(visNode) {
visNode.remove();
}, this);
this.visEdgeCollection.reset();
this.visBranchCollection.reset();
this.visTagCollection.reset();
this.visNodeMap = {};
this.rootCommit = null;
this.commitMap = {};
};
GitVisuals.prototype.tearDown = function() {
this.resetAll();
this.paper.remove();
// Unregister the refresh tree listener so we don't accumulate
// these over time. However we aren't calling tearDown in
// some places... but this is an improvement
var Main = require('../app');
Main.getEvents().removeListener('refreshTree', this._onRefreshTree);
};
GitVisuals.prototype.assignGitEngine = function(gitEngine) {
this.gitEngine = gitEngine;
this.initHeadBranch();
this.deferFlush();
};
GitVisuals.prototype.getVisualization = function() {
return this.visualization;
};
GitVisuals.prototype.initHeadBranch = function() {
// it's unfortaunte we have to do this, but the head branch
// is an edge case because it's not part of a collection so
// we can't use events to load or unload it. thus we have to call
// this ugly method which will be deleted one day
// seed this with the HEAD pseudo-branch
this.addBranchFromEvent(this.gitEngine.HEAD);
};
GitVisuals.prototype.getScreenPadding = function() {
// if we are flipping the tree, the helper bar gets in the way
var topFactor = (GlobalStateStore.getFlipTreeY()) ? 3 : 1.5;
// for now we return the node radius subtracted from the walls
return {
widthPadding: GRAPHICS.nodeRadius * 1.5,
topHeightPadding: GRAPHICS.nodeRadius * topFactor,
// we pad the bottom a lot more so the branches wont go off screen
bottomHeightPadding: GRAPHICS.nodeRadius * 5
};
};
GitVisuals.prototype.getPosBoundaries = function() {
if (this.gitEngine.hasOrigin()) {
return {
min: 0,
max: 0.5
};
} else if (this.gitEngine.isOrigin()) {
return {
min: 0.5,
max: 1
};
}
return {
min: 0,
max: 1
};
};
GitVisuals.prototype.getFlipPos = function() {
var bounds = this.getPosBoundaries();
var min = bounds.min;
var max = bounds.max;
return this.flipFraction * (max - min) + min;
};
GitVisuals.prototype.getIsGoalVis = function() {
return !!this.options.isGoalVis;
};
GitVisuals.prototype.getLevelBlob = function() {
return this.visualization.options.levelBlob || {};
};
GitVisuals.prototype.toScreenCoords = function(pos) {
if (!this.paper.width) {
throw new Error('being called too early for screen coords');
}
var padding = this.getScreenPadding();
var shrink = function(frac, total, padding) {
return padding + frac * (total - padding * 2);
};
var asymShrink = function(frac, total, paddingTop, paddingBelow) {
return paddingTop + frac * (total - paddingBelow - paddingTop);
};
var x = shrink(pos.x, this.paper.width, padding.widthPadding);
var y =
asymShrink(pos.y, this.paper.height, padding.topHeightPadding, padding.bottomHeightPadding);
if (GlobalStateStore.getFlipTreeY()) {
y = this.paper.height - y;
}
return {x: x, y: y};
};
GitVisuals.prototype.animateAllAttrKeys = function(keys, attr, speed, easing) {
var deferred = Q.defer();
var animate = function(visObj) {
visObj.animateAttrKeys(keys, attr, speed, easing);
};
this.visBranchCollection.each(animate);
this.visEdgeCollection.each(animate);
this.visTagCollection.each(animate);
_.each(this.visNodeMap, animate);
var time = (speed !== undefined) ? speed : GRAPHICS.defaultAnimationTime;
setTimeout(function() {
deferred.resolve();
}, time);
return deferred.promise;
};
GitVisuals.prototype.finishAnimation = function(speed) {
speed = speed || 1.0;
if (!speed) {
throw new Error('need speed by time i finish animation' + speed);
}
var _this = this;
var deferred = Q.defer();
var animationDone = Q.defer();
var defaultTime = GRAPHICS.defaultAnimationTime;
var nodeRadius = GRAPHICS.nodeRadius;
var textString = intl.str('solved-level');
var text = null;
var makeText = function() {
text = this.paper.text(
this.paper.width / 2,
this.paper.height / 2,
textString
);
text.attr({
opacity: 0,
'font-weight': 500,
'font-size': '32pt',
'font-family': 'Monaco, Courier, font-monospace',
stroke: '#000',
'stroke-width': 2,
fill: '#000'
});
text.animate({ opacity: 1 }, defaultTime);
}.bind(this);
// this is a BIG ANIMATION but it ends up just being
// a sweet chain of promises but is pretty nice. this is
// after I discovered promises / deferred's. Unfortunately
// I wrote a lot of the git stuff before promises, so
// that's somewhat ugly
deferred.promise
// first fade out everything but circles
.then(function() {
return this.animateAllAttrKeys(
{ exclude: ['circle'] },
{ opacity: 0 },
defaultTime * 1.1 / speed
);
}.bind(this))
// then make circle radii bigger
.then(function() {
return this.animateAllAttrKeys(
{ exclude: ['arrow', 'rect', 'path', 'text'] },
{ r: nodeRadius * 2 },
defaultTime * 1.5 / speed
);
}.bind(this))
// then shrink em super fast
.then(function() {
return this.animateAllAttrKeys(
{ exclude: ['arrow', 'rect', 'path', 'text'] },
{ r: nodeRadius * 0.75 },
defaultTime * 0.5 / speed
);
}.bind(this))
// then explode them and display text
.then(function() {
makeText();
return this.explodeNodes(speed);
}.bind(this))
.then(function() {
return this.explodeNodes(speed);
}.bind(this))
// then fade circles (aka everything) in and back
.then(function() {
return this.animateAllAttrKeys(
{ exclude: ['arrow', 'rect', 'path', 'text'] },
{},
defaultTime * 1.25
);
}.bind(this))
// then fade everything in and remove text
.then(function() {
text.animate({ opacity: 0 }, defaultTime, undefined, undefined, function() {
text.remove();
});
return this.animateAllAttrKeys(
{},
{}
);
}.bind(this))
.then(function() {
animationDone.resolve();
})
.fail(function(reason) {
console.warn('animation error' + reason);
})
.done();
// start our animation chain right away
deferred.resolve();
return animationDone.promise;
};
GitVisuals.prototype.explodeNodes = function(speed) {
var deferred = Q.defer();
var funcs = [];
_.each(this.visNodeMap, function(visNode) {
funcs.push(visNode.getExplodeStepFunc(speed));
});
var interval = setInterval(function() {
// object creation here is a bit ugly inside a loop,
// but the alternative is to just OR against a bunch
// of booleans which means the other stepFuncs
// are called unnecessarily when they have almost
// zero speed. would be interesting to see performance differences
var keepGoing = [];
_.each(funcs, function(func) {
if (func()) {
keepGoing.push(func);
}
});
if (!keepGoing.length) {
clearInterval(interval);
// next step :D wow I love promises
deferred.resolve();
return;
}
funcs = keepGoing;
}, 1/40);
return deferred.promise;
};
GitVisuals.prototype.animateAllFromAttrToAttr = function(fromSnapshot, toSnapshot, idsToOmit) {
var animate = function(obj) {
var id = obj.getID();
if (_.include(idsToOmit, id)) {
return;
}
if (!fromSnapshot[id] || !toSnapshot[id]) {
// its actually ok it doesn't exist yet
return;
}
obj.animateFromAttrToAttr(fromSnapshot[id], toSnapshot[id]);
};
this.visBranchCollection.each(animate);
this.visEdgeCollection.each(animate);
this.visTagCollection.each(animate);
_.each(this.visNodeMap, animate);
};
/***************************************
== BEGIN Tree Calculation Parts ==
_ __ __ _
\\/ / \ \//_
\ \ / __| __
\ \___/ /_____/ /
| _______ \
\ ( ) / \_\
\ /
| |
| |
____+-_=+-^ ^+-=_=__________
^^ I drew that :D
**************************************/
GitVisuals.prototype.genSnapshot = function() {
this.fullCalc();
var snapshot = {};
_.each(this.visNodeMap, function(visNode) {
snapshot[visNode.get('id')] = visNode.getAttributes();
}, this);
this.visBranchCollection.each(function(visBranch) {
snapshot[visBranch.getID()] = visBranch.getAttributes();
}, this);
this.visEdgeCollection.each(function(visEdge) {
snapshot[visEdge.getID()] = visEdge.getAttributes();
}, this);
this.visTagCollection.each(function(visTag) {
snapshot[visTag.getID()] = visTag.getAttributes();
}, this);
return snapshot;
};
GitVisuals.prototype.refreshTree = function(speed) {
if (!this.gitReady || !this.gitEngine.rootCommit) {
return;
}
// this method can only be called after graphics are rendered
this.fullCalc();
this.animateAll(speed);
};
GitVisuals.prototype.refreshTreeHarsh = function() {
this.fullCalc();
this.animateAll(0);
};
GitVisuals.prototype.animateAll = function(speed) {
this.zIndexReflow();
this.animateEdges(speed);
this.animateNodePositions(speed);
this.animateRefs(speed);
};
GitVisuals.prototype.fullCalc = function() {
this.calcTreeCoords();
this.calcGraphicsCoords();
};
GitVisuals.prototype.calcTreeCoords = function() {
// this method can only contain things that don't rely on graphics
if (!this.rootCommit) {
throw new Error('grr, no root commit!');
}
this.calcUpstreamSets();
this.calcBranchStacks();
this.calcTagStacks();
this.calcDepth();
this.calcWidth();
};
GitVisuals.prototype.calcGraphicsCoords = function() {
this.visBranchCollection.each(function(visBranch) {
visBranch.updateName();
});
this.visTagCollection.each(function(visTag) {
visTag.updateName();
});
};
GitVisuals.prototype.calcUpstreamSets = function() {
this.upstreamBranchSet = this.gitEngine.getUpstreamBranchSet();
this.upstreamHeadSet = this.gitEngine.getUpstreamHeadSet();
this.upstreamTagSet = this.gitEngine.getUpstreamTagSet();
};
GitVisuals.prototype.getCommitUpstreamBranches = function(commit) {
return this.branchStackMap[commit.get('id')];
};
GitVisuals.prototype.getBlendedHuesForCommit = function(commit) {
var branches = this.upstreamBranchSet[commit.get('id')];
if (!branches) {
throw new Error('that commit doesn\'t have upstream branches!');
}
return this.blendHuesFromBranchStack(branches);
};
GitVisuals.prototype.blendHuesFromBranchStack = function(branchStackArray) {
var hueStrings = [];
_.each(branchStackArray, function(branchWrapper) {
var fill = branchWrapper.obj.get('visBranch').get('fill');
if (fill.slice(0,3) !== 'hsb') {
// crap! convert
var color = Raphael.color(fill);
fill = 'hsb(' + String(color.h) + ',' + String(color.l);
fill = fill + ',' + String(color.s) + ')';
}
hueStrings.push(fill);
});
return blendHueStrings(hueStrings);
};
GitVisuals.prototype.getCommitUpstreamStatus = function(commit) {
if (!this.upstreamBranchSet) {
throw new Error("Can't calculate this yet!");
}
var id = commit.get('id');
var branch = this.upstreamBranchSet;
var head = this.upstreamHeadSet;
var tag = this.upstreamTagSet;
if (branch[id]) {
return 'branch';
} else if (tag[id]) {
return 'tag';
} else if (head[id]) {
return 'head';
} else {
return 'none';
}
};
GitVisuals.prototype.calcTagStacks = function() {
var tags = this.gitEngine.getTags();
var map = {};
_.each(tags, function(tag) {
var thisId = tag.target.get('id');
map[thisId] = map[thisId] || [];
map[thisId].push(tag);
map[thisId].sort(function(a, b) {
var aId = a.obj.get('id');
var bId = b.obj.get('id');
return aId.localeCompare(bId);
});
});
this.tagStackMap = map;
};
GitVisuals.prototype.calcBranchStacks = function() {
var branches = this.gitEngine.getBranches();
var map = {};
_.each(branches, function(branch) {
var thisId = branch.target.get('id');
map[thisId] = map[thisId] || [];
map[thisId].push(branch);
map[thisId].sort(function(a, b) {
var aId = a.obj.get('id');
var bId = b.obj.get('id');
if (aId == 'master' || bId == 'master') {
return aId == 'master' ? -1 : 1;
}
return aId.localeCompare(bId);
});
});
this.branchStackMap = map;
};
GitVisuals.prototype.calcWidth = function() {
this.maxWidthRecursive(this.rootCommit);
var bounds = this.getPosBoundaries();
this.assignBoundsRecursive(
this.rootCommit,
bounds.min,
bounds.max
);
};
GitVisuals.prototype.maxWidthRecursive = function(commit) {
var childrenTotalWidth = 0;
_.each(commit.get('children'), function(child) {
// only include this if we are the "main" parent of
// this child
if (child.isMainParent(commit)) {
var childWidth = this.maxWidthRecursive(child);
childrenTotalWidth += childWidth;
}
}, this);
var maxWidth = Math.max(1, childrenTotalWidth);
commit.get('visNode').set('maxWidth', maxWidth);
return maxWidth;
};
GitVisuals.prototype.assignBoundsRecursive = function(commit, min, max) {
// I always position myself within my bounds
var myWidthPos = (max + min) / 2.0;
commit.get('visNode').get('pos').x = myWidthPos;
if (commit.get('children').length === 0) {
return;
}
// i have a certain length to divide up
var myLength = max - min;
// I will divide up that length based on my children's max width in a
// basic box-flex model
var totalFlex = 0;
var children = commit.get('children');
_.each(children, function(child) {
if (child.isMainParent(commit)) {
totalFlex += child.get('visNode').getMaxWidthScaled();
}
}, this);
var prevBound = min;
_.each(children, function(child, index) {
if (!child.isMainParent(commit)) {
return;
}
var flex = child.get('visNode').getMaxWidthScaled();
var portion = (flex / totalFlex) * myLength;
var childMin = prevBound;
var childMax = childMin + portion;
this.assignBoundsRecursive(child, childMin, childMax);
prevBound = childMin + portion;
}, this);
};
GitVisuals.prototype.calcDepth = function() {
var maxDepth = this.calcDepthRecursive(this.rootCommit, 0);
if (maxDepth > 15) {
// issue warning
console.warn('graphics are degrading from too many layers');
}
var depthIncrement = this.getDepthIncrement(maxDepth);
_.each(this.visNodeMap, function(visNode) {
visNode.setDepthBasedOn(depthIncrement, this.getHeaderOffset());
}, this);
};
/***************************************
== END Tree Calculation ==
_ __ __ _
\\/ / \ \//_
\ \ / __| __
\ \___/ /_____/ /
| _______ \
\ ( ) / \_\
\ /
| |
| |
____+-_=+-^ ^+-=_=__________
^^ I drew that :D
**************************************/
GitVisuals.prototype.animateNodePositions = function(speed) {
_.each(this.visNodeMap, function(visNode) {
visNode.animateUpdatedPosition(speed);
}, this);
};
GitVisuals.prototype.addBranchFromEvent = function(branch, collection, index) {
var action = function() {
this.addBranch(branch);
}.bind(this);
if (!this.gitEngine || !this.gitReady) {
this.defer(action);
} else {
action();
}
};
GitVisuals.prototype.addBranch = function(branch) {
var visBranch = new VisBranch({
branch: branch,
gitVisuals: this,
gitEngine: this.gitEngine
});
this.visBranchCollection.add(visBranch);
if (this.gitReady) {
visBranch.genGraphics(this.paper);
} else {
this.defer(function() {
visBranch.genGraphics(this.paper);
}.bind(this));
}
};
GitVisuals.prototype.addTagFromEvent = function(tag, collection, index) {
var action = function() {
this.addTag(tag);
}.bind(this);
if (!this.gitEngine || !this.gitReady) {
this.defer(action);
} else {
action();
}
};
GitVisuals.prototype.addTag = function(tag) {
var visTag = new VisTag({
tag: tag,
gitVisuals: this,
gitEngine: this.gitEngine
});
this.visTagCollection.add(visTag);
if (this.gitReady) {
visTag.genGraphics(this.paper);
} else {
this.defer(function() {
visTag.genGraphics(this.paper);
}.bind(this));
}
};
GitVisuals.prototype.removeVisBranch = function(visBranch) {
this.visBranchCollection.remove(visBranch);
};
GitVisuals.prototype.removeVisTag = function(visTag) {
this.visTagCollection.remove(visTag);
};
GitVisuals.prototype.removeVisNode = function(visNode) {
delete this.visNodeMap[visNode.getID()];
};
GitVisuals.prototype.removeVisEdge = function(visEdge) {
this.visEdgeCollection.remove(visEdge);
};
GitVisuals.prototype.animateRefs = function(speed) {
this.visBranchCollection.each(function(visBranch) {
visBranch.animateUpdatedPos(speed);
}, this);
this.visTagCollection.each(function(visTag) {
visTag.animateUpdatedPos(speed);
}, this);
};
GitVisuals.prototype.animateEdges = function(speed) {
this.visEdgeCollection.each(function(edge) {
edge.animateUpdatedPath(speed);
}, this);
};
GitVisuals.prototype.getMinLayers = function() {
return (this.options.smallCanvas) ? 2 : 7;
};
GitVisuals.prototype.getDepthIncrement = function(maxDepth) {
// assume there are at least a number of layers until later
// to have better visuals
maxDepth = Math.max(maxDepth, this.getMinLayers());
// if we have a header, reserve space for that
var vSpace = 1.0 - this.getHeaderOffset();
var increment = vSpace / maxDepth;
return increment;
};
GitVisuals.prototype.shouldHaveHeader = function() {
return this.gitEngine.isOrigin() || this.gitEngine.hasOrigin();
};
GitVisuals.prototype.getHeaderOffset = function() {
return (this.shouldHaveHeader()) ? 0.05 : 0;
};
GitVisuals.prototype.calcDepthRecursive = function(commit, depth) {
commit.get('visNode').setDepth(depth);
var children = commit.get('children');
var maxDepth = depth;
_.each(children, function(child) {
var d = this.calcDepthRecursive(child, depth + 1);
maxDepth = Math.max(d, maxDepth);
}, this);
return maxDepth;
};
// we debounce here so we aren't firing a resize call on every resize event
// but only after they stop
GitVisuals.prototype.canvasResize = function(width, height) {
if (!this.resizeFunc) {
this.genResizeFunc();
}
this.resizeFunc(width, height);
};
GitVisuals.prototype.genResizeFunc = function() {
this.resizeFunc = _.debounce(
function(width, height) {
this.refreshTree();
}.bind(this),
200,
true
);
};
GitVisuals.prototype.addNode = function(id, commit) {
this.commitMap[id] = commit;
if (commit.get('rootCommit')) {
this.rootCommit = commit;
}
var visNode = new VisNode({
id: id,
commit: commit,
gitVisuals: this,
gitEngine: this.gitEngine
});
this.visNodeMap[id] = visNode;
if (this.gitReady) {
visNode.genGraphics(this.paper);
}
return visNode;
};
GitVisuals.prototype.addEdge = function(idTail, idHead) {
var visNodeTail = this.visNodeMap[idTail];
var visNodeHead = this.visNodeMap[idHead];
if (!visNodeTail || !visNodeHead) {
throw new Error('one of the ids in (' + idTail +
', ' + idHead + ') does not exist');
}
var edge = new VisEdge({
tail: visNodeTail,
head: visNodeHead,
gitVisuals: this,
gitEngine: this.gitEngine
});
this.visEdgeCollection.add(edge);
if (this.gitReady) {
edge.genGraphics(this.paper);
}
};
GitVisuals.prototype.zIndexReflow = function() {
this.visNodesFront();
this.visBranchesFront();
this.visTagsFront();
};
GitVisuals.prototype.visNodesFront = function() {
_.each(this.visNodeMap, function(visNode) {
visNode.toFront();
});
};
GitVisuals.prototype.visBranchesFront = function() {
this.visBranchCollection.each(function(vBranch) {
vBranch.nonTextToFront();
vBranch.textToFront();
});
this.visBranchCollection.each(function(vBranch) {
vBranch.textToFrontIfInStack();
});
};
GitVisuals.prototype.visTagsFront = function() {
this.visTagCollection.each(function(vTag) {
vTag.nonTextToFront();
vTag.textToFront();
});
this.visTagCollection.each(function(vTag) {
vTag.textToFrontIfInStack();
});
};
GitVisuals.prototype.drawTreeFromReload = function() {
this.gitReady = true;
// gen all the graphics we need
this.deferFlush();
this.calcTreeCoords();
};
GitVisuals.prototype.drawTreeFirstTime = function() {
this.gitReady = true;
this.calcTreeCoords();
_.each(this.visNodeMap, function(visNode) {
visNode.genGraphics(this.paper);
}, this);
this.visEdgeCollection.each(function(edge) {
edge.genGraphics(this.paper);
}, this);
this.visBranchCollection.each(function(visBranch) {
visBranch.genGraphics(this.paper);
}, this);
this.visTagCollection.each(function(visTag) {
visTag.genGraphics(this.paper);
}, this);
this.zIndexReflow();
};
/************************
* Random util functions, some from liquidGraph
***********************/
function blendHueStrings(hueStrings) {
// assumes a sat of 0.7 and brightness of 1
var x = 0;
var y = 0;
var totalSat = 0;
var totalBright = 0;
var length = hueStrings.length;
_.each(hueStrings, function(hueString) {
var exploded = hueString.split('(')[1];
exploded = exploded.split(')')[0];
exploded = exploded.split(',');
totalSat += parseFloat(exploded[1]);
totalBright += parseFloat(exploded[2]);
var hue = parseFloat(exploded[0]);
var angle = hue * Math.PI * 2;
x += Math.cos(angle);
y += Math.sin(angle);
});
x = x / length;
y = y / length;
totalSat = totalSat / length;
totalBright = totalBright / length;
var hue = Math.atan2(y, x) / (Math.PI * 2); // could fail on 0's
if (hue < 0) {
hue = hue + 1;
}
return 'hsb(' + String(hue) + ',' + String(totalSat) + ',' + String(totalBright) + ')';
}
exports.GitVisuals = GitVisuals;