Revamp how things move around so we don't clear everything all the time. Next up, animations?
authorBenjamin Smedberg <benjamin@smedbergs.us>
Wed, 26 Mar 2008 10:39:11 -0400
changeset 7 d500162b6b9e47431dd651a62a480c981069e85c
parent 6 43ab80cd01cd8a3f3f798c435281e40e46263997
child 8 7c39dfdaedc6f86dcfe80416acf5993899cab3c8
child 9 abcafbe52faa37620e17b50acb921820e8cb8715
push id1
push userbsmedberg@mozilla.com
push dateMon, 14 Apr 2008 15:21:26 +0000
Revamp how things move around so we don't clear everything all the time. Next up, animations?
www/index.xhtml
www/navigate.js
--- a/www/index.xhtml
+++ b/www/index.xhtml
@@ -34,25 +34,26 @@
     }
     .revision {
       position: absolute;
       border: 1px solid black;
       padding: 1px;
       width: 250px;
       text-align: center;
       max-height: 250px;
-    }
-    #revision-template {
-      display: none;
+      visibility: hidden;
     }
     .arrow {
       stroke: black;
       stroke-width: 1.5px;
       marker-end: url(#arrowhead);
     }
+    .arrow.ambiguous {
+      stroke-dasharray: 4,4;
+    }
   </style>
   <script type="text/javascript" src="jquery-1.2.3.js" />
   <script type="text/javascript" src="jquery.history.js" />
   <script type="text/javascript" src="navigate.js" />
 </head>
 <body onload="init()">
   <svg id="arrows" xmlns="http://www.w3.org/2000/svg">
     <defs>
@@ -60,17 +61,18 @@
 	      viewBox="0 0 10 10" refX="10" refY="5"
 	      markerUnits="strokeWidth"
 	      markerWidth="6"
 	      markerHeight="4"
 	      orient="auto">
 	<path d="M 0 0 L 10 5 L 0 10 z" />
       </marker>
       <line id="arrow-template" class="arrow"
-	    x1="500" y1="350" x2="200" y2="300" />
+	    x1="500" y1="350" x2="200" y2="300"
+            visibility="hidden" />
     </defs>
     <g id="scroller" transform="translate(0,0)">
     </g>
   </svg>
   <div id="inside-scrolling" />
 
   <div id="revision-template"
        class="revision">
--- a/www/navigate.js
+++ b/www/navigate.js
@@ -1,25 +1,223 @@
 const BASEURL = '/hgwebdir.cgi/';
 const SVGNS = 'http://www.w3.org/2000/svg';
 
 const REVWIDTH = 254;
 const HSPACING = 40;
 const VSPACING = 30;
 
 /**
- * map from long node strings to the JSON data.
+ * map from long node strings to Revision objects
  * The following mappings are added to the JSON:
  *   .element from the node to the element
- *   .parentArrows is a *map* of nodes to the arrow element pointing to a parent
+ *   .parentArrows is a map of arrows pointing to this element, keyed on
+ *                 parent node
+ *   .childArrows  is a map of arrows pointing from this element, keyed on
+ *                 child node
  */
 
 var revs = {};
 
-function short(node)
+function Revision(data)
+{
+    var i, d, a;
+
+    this.node = data.node;
+    this.rev = data.rev;
+    this.user = data.user;
+    this.date = data.date
+    this.description = data.description;
+    this.children = data.children;
+    this.parents = data.parents;
+
+    d = $('#revision-template').clone();
+    d.attr('id', 'rev' + shortrev(this.node));
+    $('.node', d).text(this.rev + ": " + shortrev(this.node));
+    $('.user', d).text(this.user);
+    $('.date', d).text(this.date);
+    $('.desc', d).text(this.description);
+
+    this.element = d[0];
+
+    d.appendTo('#inside-scrolling');
+    d.click(navTo);
+    this.height = measure($(d), 'height');
+
+    this.childArrows = {};
+    this.parentArrows = {};
+
+    for (i in this.children) {
+        if (revs[this.children[i]]) {
+            a = revs[this.children[i]].parentArrows[this.node];
+            this.childArrows[this.children[i]] = a;
+        }
+        else {
+            // We haven't met this child yet... make an arrow for it
+            a = $('#arrow-template').clone();
+            a.attr('id', 'ar' + shortrev(this.node) + ':' + shortrev(this.children[i]));
+            a.appendTo('#scroller');
+            this.childArrows[this.children[i]] = a[0];
+        }
+    }
+
+    for (i in this.parents) {
+        if (revs[this.parents[i]]) {
+            var a = revs[this.parents[i]].childArrows[this.node];
+            this.parentArrows[this.parents[i]] = a;
+        }
+        else {
+            // We haven't met this parent yet... make an arrow for it
+            a = $('#arrow-template').clone();
+            a.attr('id', 'ar' + shortrev(this.parents[i] + ':' + shortrev(this.node)));
+            a.appendTo('#scroller');
+            this.parentArrows[this.parents[i]] = a[0];
+        }
+    }
+
+    revs[this.node] = this;
+}
+
+Revision.prototype = {
+  visible: function r_visible() {
+    return $(this.element).css('visibility') != 'hidden';
+  },
+
+  x: function r_x() {
+    if (!this.visible())
+      throw Error("Revision " + this.node + " is not visible.");
+
+    return measure($(this.element), 'left');
+  },
+
+  y: function r_y() {
+    if (!this.visible())
+      throw Error("Revision " + this.node + " is not visible.");
+
+    return measure($(this.element), 'top');
+  },
+
+  center: function r_center() {
+    if (!this.visible())
+      throw Error("Revision " + this.node + " is not visible.");
+    
+    return {x: this.x() + REVWIDTH / 2,
+            y: this.y() + this.height / 2};
+  },
+
+  parentPoint: function r_parentPoint() {
+    if (!this.visible())
+      throw Error("Revision " + this.node + " is not visible.");
+
+    return {x: this.x(),
+            y: this.y() + this.height / 2 };
+  },
+  
+  childPoint: function r_childPoint() {
+    if (!this.visible())
+      throw Error("Revision " + this.node + " is not visible.");
+
+    var e = $(this.element);
+    return {x: this.x() + REVWIDTH,
+            y: this.y() + this.height / 2 };
+  },
+  
+  /**
+   * Move the center of the box to this point
+   */
+  moveTo: function r_move(point) {
+    var e, child, parent, p, a;
+    
+    e = $(this.element);
+    e.css('visibility', 'visible');
+    e.css('left', point.x - REVWIDTH / 2);
+    e.css('top', point.y - this.height / 2);
+    
+    p = this.childPoint();
+    for each (child in this.children) {
+      a = $(this.childArrows[child]);
+      a.attr('x1', p.x);
+      a.attr('y1', p.y);
+      if (!(child in revs)) {
+        a.attr('x2', p.x + 25);
+        a.attr('y2', p.y);
+      }
+      a.css('visibility', 'visible');
+    }
+    
+    p = this.parentPoint();
+    for each (parent in this.parents) {
+      a = $(this.parentArrows[parent]);
+      a.attr('x2', p.x);
+      a.attr('y2', p.y);
+      if (!(parent in revs)) {
+        a.attr('x1', p.x - 25);
+        a.attr('y1', p.y);
+      }
+      a.css('visibility', 'visible');
+    }
+  },
+  
+  /**
+   * Hide gc'ed revisions, set arrow visibility, and move the other end of unbounded arrows.
+   * Each node is responsible for all its parent arrows, as well as child arrows pointing to unknown revisions.
+   */
+  cleanLayout: function r_moveArrows()
+  {
+    var child, parent, p, a, i;
+
+    if (!this.gc) {
+      $(this.element).css('visibility', 'hidden');
+
+      for each (child in this.children) {
+        if (!(child in revs)) {
+          $(this.childArrows[child]).css('visibility', 'hidden');
+        }
+      }
+      
+      for each (parent in this.parents) {
+        if (!(parent in revs) || !(revs[parent].gc)) {
+          $(this.parentArrows[parent]).css('visibility', 'hidden');
+        }
+      }
+    }
+    else {
+      // We've already been positioned and are visible; all we need is to position the "other" end
+      // of arrows that point to offscreen revisions
+      p = this.childPoint();
+      for (i in this.children) {
+        child = this.children[i];
+        
+        if (!(child in revs) || !revs[child].gc) {
+          this.childArrows[child].setAttribute('class', 'arrow ambiguous');
+          a = $(this.childArrows[child]);
+          a.attr('x2', p.x + 25);
+          a.attr('y2', this.y() + (Number(i) + 0.5) * (this.height / this.children.length));
+        }
+      }
+
+      p = this.parentPoint();
+      for (i in this.parents) {
+        parent = this.parents[i];
+        
+        if (!(parent in revs) || !revs[parent].gc) {
+          this.parentArrows[parent].setAttribute('class', 'arrow ambiguous');
+          a = $(this.parentArrows[parent]);
+          a.attr('x1', p.x - 25);
+          a.attr('y1', this.y() + (Number(i) + 0.5) * (this.height / this.parents.length));
+        }
+        else {
+          this.parentArrows[parent].setAttribute('class', 'arrow');
+        }
+      }
+    }
+  }
+};
+
+function shortrev(node)
 {
     return node.slice(0, 12);
 }
 
 /**
  * Limit a string to len characters... if it is too long, add an ellipsis.
  */
 function limit(str, len)
@@ -31,154 +229,140 @@ function limit(str, len)
     return str.slice(0, len) + "…";
 }
 
 function measure(r, prop)
 {
     return Number(r.css(prop).replace('px', ''));
 }
 
-/**
- * given an object that represents a revision, return
- * a <div> element cloned from revision-template.
- */
-function makeRev(node)
+function doLayout(node)
 {
-    var rev = revs[node];
-    if (!rev) {
-        alert("Missing revision: " + node);
-        return;
+  var contextrev, rev, i, loadMore;
+  
+  loadMore = [];
+  
+  function drawChildren(rev)
+  {
+    var p, child, totalHeight, avgHeight, c, childrev;
+  
+    if (rev.children.length == 0)
+      return;
+    
+    totalHeight = 0;
+    c = 0;
+    
+    for each (child in rev.children) {
+      if (child in revs) {
+        totalHeight += revs[child].height;
+        revs[child].gc = true;
+        ++c;
+      }
+    }
+  
+    avgHeight = totalHeight / c;
+    
+    p = new Object(rev.center());
+    p.x += REVWIDTH + HSPACING;
+    p.y -= (totalHeight - avgHeight) / 2;
+    
+    var rightEdge = measure($('#inside-scrolling'), 'left') + measure($('#inside-scrolling'), 'width');
+    
+    for each (child in rev.children) {
+      if (child in revs) {
+        childrev = revs[child];
+        childrev.moveTo(p);
+        p.y += childrev.height + VSPACING;
+  
+        if (p.x < rightEdge) {
+          drawChildren(childrev);
+        }
+      }
+      else {
+        loadMore.push(child);
+      }
     }
-
-    var d = $('#revision-template').clone();
-    d.attr('id', 'rev' + rev.node);
-    $('.node', d).text(rev.rev + ": " + short(rev.node));
-    $('.user', d).text(rev.user);
-    $('.date', d).text(rev.date);
-    $('.desc', d).text(rev.description);
+  }
+  
+  function drawParents(rev)
+  {
+    var p, parent, totalHeight, avgHeight, c, parentrev;
+    
+    if (rev.parents.length == 0)
+      return;
+    
+    totalHeight = 0;
+    c = 0;
+    
+    for each (parent in rev.parents) {
+      if (parent in revs) {
+        totalHeight += revs[parent].height;
+        revs[parent].gc = true;
+        ++c;
+      }
+    }
+    
+    avgHeight = totalHeight / c;
+    
+    p = new Object(rev.center());
+    p.x -= REVWIDTH + HSPACING;
+    p.y -= (totalHeight - avgHeight) / 2;
+    
+    var leftEdge = measure($('#inside-scrolling'), 'left');
+    
+    for each (parent in rev.parents) {
+      if (parent in revs) {
+        parentrev = revs[parent];
+        parentrev.moveTo(p);
+        p.y += parentrev.height + VSPACING;
+        
+        if (p.x > leftEdge) {
+          drawParents(parentrev);
+        }
+      }
+      else {
+        loadMore.push(parent);
+      }
+    }
+  }  
+  
+  contextrev = revs[node];
+    
+  document.title = $('#select-repo')[0].value + " revision " +
+    contextrev.rev + ": " +
+    limit(contextrev.description, 60);
 
-    rev.element = d[0];
-
-    d.appendTo('#inside-scrolling');
-    d.click(navTo);
-
-    return d;
-}
+  // All the nodes which have .gc = false at the end will be hidden
+  for each (rev in revs)
+    rev.gc = false;
 
-function drawArrow(p1, p2)
-{
-    var a = $('#arrow-template').clone();
-    a.removeAttr('id');
-    a.attr('x1', p1.x);
-    a.attr('x2', p2.x);
-    a.attr('y1', p1.y);
-    a.attr('y2', p2.y);
-    a.appendTo('#scroller');
-    return a;
+  contextrev.gc = true;
+  i = $('#inside-scrolling');
+  contextrev.moveTo({x: measure(i, 'width') / 2,
+                     y: measure(i, 'height') / 2});
+  
+  drawChildren(contextrev);
+  drawParents(contextrev);
+  
+  for each (rev in revs)
+    rev.cleanLayout();
 }
 
-function drawRelations(node, kind, limit)
-{
-    var context = revs[node];
-    var count = context[kind].length;
 
-    if (count == 0)
-        return;
-
-    var relations = [];
-    var totalHeight = (count - 1) * 30;
-
-    for (var i = 0; i < count; ++i) {
-        var relationnode = context[kind][i];
-        var relationrev = revs[relationnode];
-
-        var relationel = makeRev(context[kind][i])
-        relationrev.element = relationel;
-
-        relations.push(relationel);
-        totalHeight += measure(relations[i], 'height');
-    }
-
-    var left = measure($(context.element), 'left');
-    var contextpoint = {};
-    switch (kind) {
-    case 'children':
-        contextpoint.x = left + REVWIDTH;
-        left += REVWIDTH + HSPACING;
-        break;
-    
-    case 'parents':
-        contextpoint.x = left;
-        left -= REVWIDTH + HSPACING;
-        break;
-
-    default:
-        alert("Unknown relationship!");
-        return;
-    }
-
-    var el = $(context.element);
-    var top = measure(el, 'top') + measure(el, 'height') / 2;
-
-    contextpoint.y = top;
-    
-    top -= (totalHeight / 2);
 
-    for (i = 0; i < count; ++i) {
-        var relationnode = context[kind][i];
-        var relationrev = revs[relationnode];
-
-        relations[i].css('left', left);
-        relations[i].css('top', top);
-
-        var rheight = measure(relations[i], 'height');
-
-        if (kind == 'children') {
-            var arrowpoint = {x: left,
-                              y: top + rheight / 2};
-            var a = drawArrow(contextpoint, arrowpoint);
-            relationrev.parentArrows[node] = a;
-        } 
-        else {
-            var arrowpoint = {x: left + REVWIDTH,
-                              y: top + rheight / 2};
-            var a = drawArrow(arrowpoint, contextpoint);
-            context.parentArrows[relationnode] = a;
-        }
-
-        top += rheight + VSPACING;
+function processContextData(data)
+{
+  for each (var node in data.nodes) {
+      if (node.node in revs)
+          continue;
 
-        if (limit > 1) {
-            drawRelations(relationnode, kind, limit - 1);
-        }
-    }
-}
-
-function drawContext(data)
-{
-    for each (var node in data.nodes) {
-        var nodeid = node.node;
+      new Revision(node);
+  }
 
-        if (!(nodeid in revs)) {
-            revs[nodeid] = node;
-            revs[nodeid].element = null;
-            revs[nodeid].parentArrows = {};
-        }
-    }
-
-    var center = makeRev(data.context);
-    center.css('left', (measure($('#inside-scrolling'), 'width') - REVWIDTH) / 2);
-    center.css('top', 200 - measure(center, 'height') / 2);
-
-    document.title = $('#select-repo')[0].value + " revision " +
-        revs[data.context].rev + ": " +
-        limit(revs[data.context].description, 60);
-
-    drawRelations(data.context, 'parents', 2);
-    drawRelations(data.context, 'children', 2);
+  doLayout(data.context);
 }
 
 function startContext(hash)
 {
     var repo, context;
 
     if (hash == '') {
         repo = $('#select-repo')[0].value;
@@ -187,32 +371,23 @@ function startContext(hash)
     else {
         var l = hash.split(':');
         repo = l[0];
         context = l[1];
         $('#select-repo')[0].value = repo;
         $('#node-input')[0].value = context;
     }
 
-    /* Clear out lots of everything */
-    for (var rev in revs) {
-        rev.element = null;
-        rev.parentArrows = {};
-    }
-
-    $('#inside-scrolling').empty();
-    $('#scroller').empty();
-
     $.ajax({'url': BASEURL + repo + "/jsonfamily?node=" + context,
             'type': 'GET',
             'dataType': 'json',
             error: function(xhr, textStatus) {
                 alert("Request failed: " + textStatus);
             },
-            success: drawContext
+            success: processContextData
            });
 }
 
 function navTo()
 {
     $('#node-input')[0].value = this.id.replace('rev', '');
     setHash();
 }