var _ = require('underscore'); var Backbone = require('backbone'); var GRAPHICS = require('../util/constants').GRAPHICS; var VisBase = require('../visuals/tree').VisBase; var VisNode = VisBase.extend({ defaults: { depth: undefined, maxWidth: null, outgoingEdges: null, circle: null, text: null, id: null, pos: null, radius: null, commit: null, animationSpeed: GRAPHICS.defaultAnimationTime, animationEasing: GRAPHICS.defaultEasing, fill: GRAPHICS.defaultNodeFill, 'stroke-width': GRAPHICS.defaultNodeStrokeWidth, stroke: GRAPHICS.defaultNodeStroke }, getID: function() { return this.get('id'); }, validateAtInit: function() { if (!this.get('id')) { throw new Error('need id for mapping'); } if (!this.get('commit')) { throw new Error('need commit for linking'); } if (!this.get('pos')) { this.set('pos', { x: Math.random(), y: Math.random() }); } }, initialize: function() { this.validateAtInit(); // shorthand for the main objects this.gitVisuals = this.get('gitVisuals'); this.gitEngine = this.get('gitEngine'); this.set('outgoingEdges', []); }, setDepth: function(depth) { // for merge commits we need to max the depths across all this.set('depth', Math.max(this.get('depth') || 0, depth)); }, setDepthBasedOn: function(depthIncrement) { if (this.get('depth') === undefined) { debugger; throw new Error('no depth yet!'); } var pos = this.get('pos'); pos.y = this.get('depth') * depthIncrement; }, getMaxWidthScaled: function() { // returns our max width scaled based on if we are visible // from a branch or not var stat = this.gitVisuals.getCommitUpstreamStatus(this.get('commit')); var map = { branch: 1, head: 0.3, none: 0.1 }; if (map[stat] === undefined) { throw new Error('bad stat'); } return map[stat] * this.get('maxWidth'); }, toFront: function() { this.get('circle').toFront(); this.get('text').toFront(); }, getOpacity: function() { var map = { 'branch': 1, 'head': GRAPHICS.upstreamHeadOpacity, 'none': GRAPHICS.upstreamNoneOpacity }; var stat = this.gitVisuals.getCommitUpstreamStatus(this.get('commit')); if (map[stat] === undefined) { throw new Error('invalid status'); } return map[stat]; }, getTextScreenCoords: function() { return this.getScreenCoords(); }, getAttributes: function() { var pos = this.getScreenCoords(); var textPos = this.getTextScreenCoords(); var opacity = this.getOpacity(); return { circle: { cx: pos.x, cy: pos.y, opacity: opacity, r: this.getRadius(), fill: this.getFill(), 'stroke-width': this.get('stroke-width'), stroke: this.get('stroke') }, text: { x: textPos.x, y: textPos.y, opacity: opacity } }; }, highlightTo: function(visObj, speed, easing) { // a small function to highlight the color of a node for demonstration purposes var color = visObj.get('fill'); var attr = { circle: { fill: color, stroke: color, 'stroke-width': this.get('stroke-width') * 5 }, text: {} }; this.animateToAttr(attr, speed, easing); }, animateUpdatedPosition: function(speed, easing) { var attr = this.getAttributes(); this.animateToAttr(attr, speed, easing); }, animateFromAttrToAttr: function(fromAttr, toAttr, speed, easing) { // an animation of 0 is essentially setting the attribute directly this.animateToAttr(fromAttr, 0); this.animateToAttr(toAttr, speed, easing); }, animateToSnapshot: function(snapShot, speed, easing) { if (!snapShot[this.getID()]) { return; } this.animateToAttr(snapShot[this.getID()], speed, easing); }, animateToAttr: function(attr, speed, easing) { if (speed === 0) { this.get('circle').attr(attr.circle); this.get('text').attr(attr.text); return; } var s = speed !== undefined ? speed : this.get('animationSpeed'); var e = easing || this.get('animationEasing'); this.get('circle').stop().animate(attr.circle, s, e); this.get('text').stop().animate(attr.text, s, e); if (easing == 'bounce' && attr.circle && attr.circle.cx !== undefined && attr.text && attr.text.x !== undefined ) { // animate the x attribute without bouncing so it looks like there's // gravity in only one direction. Just a small animation polish this.get('circle').animate(attr.circle.cx, s, 'easeInOut'); this.get('text').animate(attr.text.x, s, 'easeInOut'); } }, getScreenCoords: function() { var pos = this.get('pos'); return this.gitVisuals.toScreenCoords(pos); }, getRadius: function() { return this.get('radius') || GRAPHICS.nodeRadius; }, getParentScreenCoords: function() { return this.get('commit').get('parents')[0].get('visNode').getScreenCoords(); }, setBirthPosition: function() { // utility method for animating it out from underneath a parent var parentCoords = this.getParentScreenCoords(); this.get('circle').attr({ cx: parentCoords.x, cy: parentCoords.y, opacity: 0, r: 0 }); this.get('text').attr({ x: parentCoords.x, y: parentCoords.y, opacity: 0 }); }, setBirthFromSnapshot: function(beforeSnapshot) { // first get parent attribute // woof bad data access. TODO var parentID = this.get('commit').get('parents')[0].get('visNode').getID(); var parentAttr = beforeSnapshot[parentID]; // then set myself faded on top of parent this.get('circle').attr({ opacity: 0, r: 0, cx: parentAttr.circle.cx, cy: parentAttr.circle.cy }); this.get('text').attr({ opacity: 0, x: parentAttr.text.x, y: parentAttr.text.y }); // then do edges var parentCoords = { x: parentAttr.circle.cx, y: parentAttr.circle.cy }; this.setOutgoingEdgesBirthPosition(parentCoords); }, setBirth: function() { this.setBirthPosition(); this.setOutgoingEdgesBirthPosition(this.getParentScreenCoords()); }, setOutgoingEdgesOpacity: function(opacity) { _.each(this.get('outgoingEdges'), function(edge) { edge.setOpacity(opacity); }); }, animateOutgoingEdgesToAttr: function(snapShot, speed, easing) { _.each(this.get('outgoingEdges'), function(edge) { var attr = snapShot[edge.getID()]; edge.animateToAttr(attr); }, this); }, animateOutgoingEdges: function(speed, easing) { _.each(this.get('outgoingEdges'), function(edge) { edge.animateUpdatedPath(speed, easing); }, this); }, animateOutgoingEdgesFromSnapshot: function(snapshot, speed, easing) { _.each(this.get('outgoingEdges'), function(edge) { var attr = snapshot[edge.getID()]; edge.animateToAttr(attr, speed, easing); }, this); }, setOutgoingEdgesBirthPosition: function(parentCoords) { _.each(this.get('outgoingEdges'), function(edge) { var headPos = edge.get('head').getScreenCoords(); var path = edge.genSmoothBezierPathStringFromCoords(parentCoords, headPos); edge.get('path').stop().attr({ path: path, opacity: 0 }); }, this); }, parentInFront: function() { // woof! talk about bad data access this.get('commit').get('parents')[0].get('visNode').toFront(); }, getFontSize: function(str) { if (str.length < 3) { return 12; } else if (str.length < 5) { return 10; } else { return 8; } }, getFill: function() { // first get our status, might be easy from this var stat = this.gitVisuals.getCommitUpstreamStatus(this.get('commit')); if (stat == 'head') { return GRAPHICS.headRectFill; } else if (stat == 'none') { return GRAPHICS.orphanNodeFill; } // now we need to get branch hues return this.gitVisuals.getBlendedHuesForCommit(this.get('commit')); }, attachClickHandlers: function() { var commandStr = 'git checkout ' + this.get('commit').get('id'); var Main = require('../app'); _.each([this.get('circle'), this.get('text')], function(rObj) { rObj.click(function() { Main.getEvents().trigger('commandSubmitted', commandStr); }); $(rObj.node).css('cursor', 'pointer'); }); }, setOpacity: function(opacity) { opacity = (opacity === undefined) ? 1 : opacity; // set the opacity on my stuff var keys = ['circle', 'text']; _.each(keys, function(key) { this.get(key).attr({ opacity: opacity }); }, this); }, remove: function() { this.removeKeys(['circle'], ['text']); // needs a manual removal of text for whatever reason this.get('text').remove(); this.gitVisuals.removeVisNode(this); }, removeAll: function() { this.remove(); _.each(this.get('outgoingEdges'), function(edge) { edge.remove(); }, this); }, getExplodeStepFunc: function() { var circle = this.get('circle'); // decide on a speed var speedMag = 20; // aim upwards var angle = Math.PI + Math.random() * 1 * Math.PI; var gravity = 1 / 5; var drag = 1 / 100; var vx = speedMag * Math.cos(angle); var vy = speedMag * Math.sin(angle); var x = circle.attr('cx'); var y = circle.attr('cy'); var maxWidth = this.gitVisuals.paper.width; var maxHeight = this.gitVisuals.paper.height; var elasticity = 0.8; var dt = 1.0; var stepFunc = function() { // lol epic runge kutta here... not vy += gravity * dt - drag * vy; vx -= drag * vx; x += vx * dt; y += vy * dt; if (x < 0 || x > maxWidth) { vx = elasticity * -vx; x = (x < 0) ? 0 : maxWidth; } if (y < 0 || y > maxHeight) { vy = elasticity * -vy; y = (y < 0) ? 0 : maxHeight; } circle.attr({ cx: x, cy: y }); // continuation calculation if ((vx * vx + vy * vy) < 0.01 && Math.abs(y - maxHeight) === 0) { // dont need to animate anymore, we are on ground return false; } // keep animating! return true; }; return stepFunc; }, genGraphics: function() { var paper = this.gitVisuals.paper; var pos = this.getScreenCoords(); var textPos = this.getTextScreenCoords(); var circle = paper.circle( pos.x, pos.y, this.getRadius() ).attr(this.getAttributes().circle); var text = paper.text(textPos.x, textPos.y, String(this.get('id'))); text.attr({ 'font-size': this.getFontSize(this.get('id')), 'font-weight': 'bold', 'font-family': 'Monaco, Courier, font-monospace', opacity: this.getOpacity() }); this.set('circle', circle); this.set('text', text); this.attachClickHandlers(); } }); exports.VisNode = VisNode;