cytoscape / cytoscape.js

Graph theory (network) library for visualisation and analysis
https://js.cytoscape.org
MIT License
10.09k stars 1.64k forks source link

edge-text-rotation: autorotate | none #714

Closed ktei closed 9 years ago

ktei commented 9 years ago

At the moment, all edge label is shown horizontally, so when there are many edges, some edge labels will overlap each other. Is that possible to make each label align with the edge direction? For example:

capture

My drawing is terrible but I think you get my idea here.

maxkfranz commented 9 years ago

We've thought about this before, and haven't added it on purpose thusfar. It's generally a bad idea in terms of legibility, and experts in infoviz like Edward Tufte explicitly mention this in their books and lectures.

There may be some very few edgecases where it would be acceptable, like in languages that use the Chinese writing system. For example, it may be OK to write Japanese labels character-by-character, top-to-bottom in the vertical case in your figure. However, since the nodes can be moved, this wouldn't hold in general -- and the Japanese example would "jump" to the normal left-to-right direction when the edge isn't vertically aligned.

ktei commented 9 years ago

@maxkfranz, thank you for the reply. Actually in our project we just want to reduce label overlapping. As for the language reading issue you mentioned, yes, it will be a concern but still we want to try it and let users give feedback. If possible, could you give some tips as to how to implement this? So basically whenever you zoom, pan or drag nodes, the labels will keep align with edge direction. Thank you.

maxkfranz commented 9 years ago

You'd need to introduce a new style property like edge-text-rotation that could take on values none or autorotate.

Then you'd need to test for the autorotate value in the render for edge label drawing. There's already a calculated label position that you can use, but you'd have to rotate the canvas context based on the edge type (there are many types and subtypes, denoted in ele._private.rscratch). Then you'd have to apply the inverse transform to reset the canvas after drawing the text.

maxkfranz commented 9 years ago

If you want to reduce label overlap, you might try to layout the graph so that it's more spread out. You can also make edge labels a smaller font size.

maxkfranz commented 9 years ago

Also note that all those additional rotations are going to have a large effect on rendering performance, especially since it's on text.

ktei commented 9 years ago

@maxkfranz thank you! Let me try it first.

ktei commented 9 years ago

Hi, @maxkfranz , I did something as you suggested, and I got some result already but I'm having an issue that after what I did, I lost control of the font-size of edge labels and node labels.

First, let me show you what I did: In the file Renderer.canvas.drawing-label-text.js, I found the method drawEdgeText and I added something to it so currently the method is like this:

// Draw edge text
  CanvasRenderer.prototype.drawEdgeText = function (context, edge) {
      var text = edge._private.style['content'].strValue;
      if (!text || text.match(/^\s+$/)) {
          return;
      }

      if (this.hideEdgesOnViewport && (this.dragData.didDrag || this.pinching || this.hoverData.dragging || this.data.wheel || this.swipePanning)) { return; } // save cycles on pinching

      var computedSize = edge._private.style['font-size'].pxValue * edge.cy().zoom();
      var minSize = edge._private.style['min-zoomed-font-size'].pxValue;

      if (computedSize < minSize) {
          return;
      }

      // Calculate text draw position
      context.textAlign = 'center';
      context.textBaseline = 'middle';

      this.recalculateEdgeLabelProjection( edge );

      var rs = edge._private.rscratch;

      // Rotate the label to make it align with the edge
      context.save(); // This might have already messed things up
      context.translate(rs.labelX, rs.labelY);

      var deltaX = rs.endX - rs.startX;
      var deltaY = rs.endY - rs.startY;

      var angleToRoate = Math.atan2(deltaY, deltaX);
      var fmod = function (x, m) { return ((x % m) + m) % m; };
      var newAngle = fmod((angleToRoate + Math.PI / 2), Math.PI) - Math.PI / 2;

      context.rotate(newAngle);
      this.drawText(context, edge, 0, 0);//rs.labelX, rs.labelY);

      //this.drawText(context, edge, rs.labelX, rs.labelY);

      context.restore();
  };

And I styled the node and edge like this:

                var style = cytoscape.stylesheet()
                    .selector('node').css({
                        'width': '15px',
                        'height': '15px',
                        'border-width': 1
                    });
                    style.selector('edge').css({
                        'width': 0.5
                    });
                    style.selector('node, edge').css({
                        'font-size': '4px' // Even 4px makes the font super big
                    });

But after these changes, the labels are so large now. So I guess I must have messed up something in drawEdgeText so that the labels (even including the node labels) do not respect the style anymore. It seems like the font size goes back to 10px instead of 4px.

Could you have a look at what I've done and give some help here? I'd love to make this work and contribute it back to this project actually. Thank you : )

maxkfranz commented 9 years ago

Some initial thoughts:

(1) Don't use context.save(). It's expensive and less flexible, and it's relatively easy to do the inverse transform yourself, i.e.:

context.rotate(newAngle);
context.translate(rs.labelX, rs.labelY);

// ...

context.translate(-rs.labelX, -rs.labelY);
context.rotate(-newAngle);

(2) I suspect the .save() is destroying the previously set font, making you lose the size. It looks like the font is set in another function, earlier.

(3) I realise this is an early implementation, but make sure to keep the old code commented like you have instead of deleted. It will be important once everything's working to have ifs such that the additional expense of rotations and translations can be avoided if the labels aren't autorotated in the style.

ktei commented 9 years ago

We got some progress on this: In src/extensions/renderer.canvas.drawing-label-text.js, we added some code and removed one line like below:

     ...
     // this.recalculateEdgeLabelProjection( edge );

     // Add these lines to make rotated edge text work - start
     var rs = edge._private.rscratch;
     var deltaX = rs.endX - rs.startX;
     var deltaY = rs.endY - rs.startY;
     var angleToRotate = Math.atan2(deltaY, deltaX);
     var fmod = function (x, m) { return ((x % m) + m) % m; };
     var newAngle = fmod((angleToRotate + Math.PI / 2), Math.PI) - Math.PI / 2;
     context.translate(rs.labelX, rs.labelY);
     context.rotate(newAngle);

     // Rotate the label to make it align with the edge
     this.drawText(context, edge, 0, -7);
     context.rotate(-newAngle);
     context.translate(-rs.labelX, -rs.labelY);
     // end

     // this.drawText(context, edge, rs.labelX, rs.labelY); // This line is removed

This worked until we noticed a small issue when we do animation. For example, during an animation in which we move one node close to another, there's some collapsed text (very tiny) somewhere on the canvas, flashing all the time until the animation ends.

Can you give some tips how we can fix this thing? Thank you very much!

maxkfranz commented 9 years ago

Maybe this will do the trick: https://github.com/ktei/cytoscape.js/pull/1

maxkfranz commented 9 years ago

If you could test it out and maybe have a pull request to the unstable branch once you're satisfied, that would be great.

ktei commented 9 years ago

@maxkfranz I've pulled the code and tried it out, but still I can reproduce this issue: flashing_text I can't give you the whole screenshot here because of some company policy, but as you can see that during the animation, there's collapsed text keeping flashing there (upper left). I currently have no idea how this would happen (seems to me something is not cleaned up during the redrawing per frame).

To reproduce, you can randomly build a graph and then animate a node to another node. Also note that you probably need to zoom out the graph a bit otherwise you can't see the flashing.

someCyNode.animate({
  position: // put a new position here
}, {
  duration: 500
})
ktei commented 9 years ago

So the easiest way to reproduce this. I git cloned the project where you did the tweak, and built it. Then I changed a little bit to one of the demos, and it comes down to this index.html:

<html>
    <head>
        <script src="http://ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js"></script>
        <script src="build/cytoscape.js"></script>
    </head>

    <body>
        <style>
          body { 
            font: 14px helvetica neue, helvetica, arial, sans-serif;
          }

          #cy {
            height: 100%;
            width: 100%;
            position: absolute;
            left: 0;
            top: 0;
          }

          #eat {
            position: absolute;
            left: 1em;
            top: 1em;
            font-size: 1em;
            z-index: -1;
            color: #c88;
          }
        </style>
        <div id="cy"></div>
        <script>
            $(function(){ // on dom ready

                // photos from flickr with creative commons license

                var cy = cytoscape({
                  container: document.getElementById('cy'),

                  style: cytoscape.stylesheet()
                    .selector('node')
                      .css({
                        'height': 80,
                        'width': 80,
                        'background-fit': 'cover',
                        'border-color': '#000',
                        'border-width': 3,
                        'border-opacity': 0.5
                      })
                    .selector('.eating')
                      .css({
                        'border-color': 'red'
                      })
                    .selector('.eater')
                      .css({
                        'border-width': 9
                      })
                    .selector('edge')
                      .css({
                        'width': 6,
                        'target-arrow-shape': 'triangle',
                        'line-color': '#ffaaaa',
                        'target-arrow-color': '#ffaaaa',
                        'content': 'data(label)',
                        'overlay-opacity': 0,
                        'edge-text-rotation': 'autorotate'
                      })
                    .selector('#bird')
                      .css({
                        'background-image': 'https://farm8.staticflickr.com/7272/7633179468_3e19e45a0c_b.jpg'
                      })
                    .selector('#cat')
                      .css({
                        'background-image': 'https://farm2.staticflickr.com/1261/1413379559_412a540d29_b.jpg'
                      })
                    .selector('#ladybug')
                      .css({
                        'background-image': 'https://farm4.staticflickr.com/3063/2751740612_af11fb090b_b.jpg'
                      })
                  .selector('#aphid')
                      .css({
                        'background-image': 'https://farm9.staticflickr.com/8316/8003798443_32d01257c8_b.jpg'
                      })
                  .selector('#rose')
                      .css({
                        'background-image': 'https://farm6.staticflickr.com/5109/5817854163_eaccd688f5_b.jpg'
                      })
                  .selector('#grasshopper')
                      .css({
                        'background-image': 'https://farm7.staticflickr.com/6098/6224655456_f4c3c98589_b.jpg'
                      })
                  .selector('#plant')
                      .css({
                        'background-image': 'https://farm1.staticflickr.com/231/524893064_f49a4d1d10_z.jpg'
                      })
                  .selector('#wheat')
                      .css({
                        'background-image': 'https://farm3.staticflickr.com/2660/3715569167_7e978e8319_b.jpg'
                      }),

                  elements: {
                    nodes: [
                      { data: { id: 'cat' } },
                      { data: { id: 'bird' } },
                      { data: { id: 'ladybug' } },
                      { data: { id: 'aphid' } },
                      { data: { id: 'rose' } },
                      { data: { id: 'grasshopper' } },
                      { data: { id: 'plant' } },
                      { data: { id: 'wheat' } }
                    ],
                    edges: [
                      { data: { source: 'cat', target: 'bird', label: 'cat' } },
                      { data: { source: 'bird', target: 'ladybug', label: 'bird' } },
                      { data: { source: 'bird', target: 'grasshopper', label: 'bird' } },
                      { data: { source: 'grasshopper', target: 'plant', label: 'grasshopper' } },
                      { data: { source: 'grasshopper', target: 'wheat', label: 'grasshopper' } },
                      { data: { source: 'ladybug', target: 'aphid', label: 'ladybug' } },
                      { data: { source: 'aphid', target: 'rose', label: 'aphid' } }
                    ]
                  },

                  layout: {
                    name: 'breadthfirst',
                    directed: true,
                    padding: 10
                  }
                }); // cy init

                cy.on('tap', 'node', function(){
                  var nodes = this;
                  var tapped = nodes;
                  var food = [];

                  nodes.addClass('eater');

                  for(;;){
                    var connectedEdges = nodes.connectedEdges(function(){
                      return !this.target().anySame( nodes );
                    });

                    var connectedNodes = connectedEdges.targets();

                    Array.prototype.push.apply( food, connectedNodes );

                    nodes = connectedNodes;

                    if( nodes.empty() ){ break; }
                  }

                  var delay = 0;
                  var duration = 500;
                  for( var i = food.length - 1; i >= 0; i-- ){ (function(){
                    var thisFood = food[i];
                    var eater = thisFood.connectedEdges(function(){
                      return this.target().same(thisFood);
                    }).source();

                    thisFood.delay( delay, function(){
                      eater.addClass('eating');
                    } ).animate({
                      position: eater.position(),
                      css: {
                        'width': 10,
                        'height': 10,
                        'border-width': 0,
                        'opacity': 0
                      }
                    }, {
                      duration: duration,
                      complete: function(){
                        thisFood.remove();
                      }
                    });

                    delay += duration;
                  })(); } // for

                }); // on tap

            }); // on dom ready
        </script>
    </body>
</html>

If you copy paste the file to the project root folder, and then build cytoscape, and start index.html in chrome, zoom out a bit, click on a node to let the animation run, and then you'll be able to reproduce the issue.

Note that in the project you forked, I changed a bit for gulpfile.js to eliminate the layout and some other extensions from the build, but to run this example, you need your original gulpfile.js

    'src/extensions/renderer.canvas.define-and-init-etc.js',
    'src/extensions/renderer.canvas.*.js',
    // 'src/extensions/*.js' // I commented this out, so you need to uncomment this
    'src/extensions/renderer.null.js', // I put this in. Comment this out
    'src/extensions/layout.preset.js' // I put this in. Comment this out

A screenshot is also attached here (Please have a look at the upper left, tiny text there): capture_flashing

maxkfranz commented 9 years ago

Maybe this tweakfix commit will help?

ktei commented 9 years ago

@maxkfranz I tested the tweakfix and it fixed the issue. Thank you! I also created a new branch, cherry-picked the commits specific to this rotated label feature and made a pull request out of it. Please have a look at the pull request.

maxkfranz commented 9 years ago

Looks good. The labels are positioned along the edge line rather than centred on the line, but maybe that's preferable for this usecase anyway. I suppose we could always introduce edge-text-rotation: midautorotate in future if there's interest.

Closing for now