42

I have an HTML page with some textual spans marked up something like this:

...
<span id="T2" class="Protein">p50</span>
...
<span id="T3" class="Protein">p65</span>
...
<span id="T34" ids="T2 T3" class="Positive_regulation">recruitment</span>
...

I.e. each span has an ID and refers to zero or more spans via their IDs.

I would like to visualize these references as arrows.

Two questions:

  • How can I map an ID of a span to the screen coordinates of the rendering of the span?
  • How do I draw arrows going from one rendering to another?

The solution should work in Firefox, working in other browsers is a plus but not really necessary. The solution could use jQuery, or some other lightweight JavaScript library.

Ilmari Karonen
  • 49,047
  • 9
  • 93
  • 153
Kaarel
  • 10,554
  • 4
  • 56
  • 78

10 Answers10

72

This captured my interest for long enough to produce a little test. The code is below, and you can see it in action

screenshot

It lists all the spans on the page (might want to restrict that to just those with ids starting with T if that is suitable), and uses the 'ids' attribute to build the list of links. Using a canvas element behind the spans, it draws arc arrows alternately above and below the spans for each source span.

<script type="application/x-javascript"> 

function generateNodeSet() {
  var spans = document.getElementsByTagName("span");
  var retarr = [];
  for(var i=0;i<spans.length; i++) { 
     retarr[retarr.length] = spans[i].id; 
  } 
  return retarr; 
} 

function generateLinks(nodeIds) { 
  var retarr = []; 
  for(var i=0; i<nodeIds.length; i++) { 
    var id = nodeIds[i];
    var span = document.getElementById(id); 
    var atts = span.attributes; 
    var ids_str = false; 
    if((atts.getNamedItem) && (atts.getNamedItem('ids'))) { 
      ids_str = atts.getNamedItem('ids').value; 
    } 
    if(ids_str) { 
      retarr[id] = ids_str.split(" ");
    }
  } 
  return retarr; 
} 
    
// degrees to radians, because most people think in degrees
function degToRad(angle_degrees) {
   return angle_degrees/180*Math.PI;
}
// draw a horizontal arc
//   ctx: canvas context;
//   inax: first x point
//   inbx: second x point
//   y: y value of start and end
//   alpha_degrees: (tangential) angle of start and end
//   upside: true for arc above y, false for arc below y.
function drawHorizArc(ctx, inax, inbx, y, alpha_degrees, upside)
{
  var alpha = degToRad(alpha_degrees);
  var startangle = (upside ? ((3.0/2.0)*Math.PI + alpha) : ((1.0/2.0)*Math.PI - alpha));
  var endangle = (upside ? ((3.0/2.0)*Math.PI - alpha) : ((1.0/2.0)*Math.PI + alpha));

  var ax=Math.min(inax,inbx);
  var bx=Math.max(inax,inbx);

  // tan(alpha) = o/a = ((bx-ax)/2) / o
  // o = ((bx-ax)/2/tan(alpha))
  // centre of circle is (bx+ax)/2, y-o
  var circleyoffset = ((bx-ax)/2)/Math.tan(alpha);
  var circlex = (ax+bx)/2.0;
  var circley = y + (upside ? 1 : -1) * circleyoffset;
  var radius = Math.sqrt(Math.pow(circlex-ax,2) + Math.pow(circley-y,2));

  ctx.beginPath();
  if(upside) {
      ctx.moveTo(bx,y);
    ctx.arc(circlex,circley,radius,startangle,endangle,1);
  } else {
    ctx.moveTo(bx,y);
    ctx.arc(circlex,circley,radius,startangle,endangle,0);
  }
  ctx.stroke();
}


// draw the head of an arrow (not the main line)
//  ctx: canvas context
//  x,y: coords of arrow point
//  angle_from_north_clockwise: angle of the line of the arrow from horizontal
//  upside: true=above the horizontal, false=below
//  barb_angle: angle between barb and line of the arrow
//  filled: fill the triangle? (true or false)
function drawArrowHead(ctx, x, y, angle_from_horizontal_degrees, upside, //mandatory
                       barb_length, barb_angle_degrees, filled) {        //optional
   (barb_length==undefined) && (barb_length=13);
   (barb_angle_degrees==undefined) && (barb_angle_degrees = 20);
   (filled==undefined) && (filled=true);
   var alpha_degrees = (upside ? -1 : 1) * angle_from_horizontal_degrees; 
  
   //first point is end of one barb
   var plus = degToRad(alpha_degrees - barb_angle_degrees);
   a = x + (barb_length * Math.cos(plus));
   b = y + (barb_length * Math.sin(plus));
   
   //final point is end of the second barb
   var minus = degToRad(alpha_degrees + barb_angle_degrees);
   c = x + (barb_length * Math.cos(minus));
   d = y + (barb_length * Math.sin(minus));

   ctx.beginPath();
   ctx.moveTo(a,b);
   ctx.lineTo(x,y);
   ctx.lineTo(c,d);
   if(filled) {
    ctx.fill();
   } else {
    ctx.stroke();
   }
   return true;
}

// draw a horizontal arcing arrow
//  ctx: canvas context
//  inax: start x value
//  inbx: end x value
//  y: y value
//  alpha_degrees: angle of ends to horizontal (30=shallow, >90=silly)
function drawHorizArcArrow(ctx, inax, inbx, y,                 //mandatory
                           alpha_degrees, upside, barb_length) { //optional
   (alpha_degrees==undefined) && (alpha_degrees=45);
   (upside==undefined) && (upside=true);
   drawHorizArc(ctx, inax, inbx, y, alpha_degrees, upside);
   if(inax>inbx) { 
    drawArrowHead(ctx, inbx, y, alpha_degrees*0.9, upside, barb_length); 
   } else { 
    drawArrowHead(ctx, inbx, y, (180-alpha_degrees*0.9), upside, barb_length); 
   }
   return true;
}


function drawArrow(ctx,fromelem,toelem,    //mandatory
                     above, angle) {        //optional
  (above==undefined) && (above = true);
  (angle==undefined) && (angle = 45); //degrees 
  midfrom = fromelem.offsetLeft + (fromelem.offsetWidth / 2) - left - tofromseparation/2; 
  midto   =   toelem.offsetLeft + (  toelem.offsetWidth / 2) - left + tofromseparation/2;
  //var y = above ? (fromelem.offsetTop - top) : (fromelem.offsetTop + fromelem.offsetHeight - top);
  var y = fromelem.offsetTop + (above ? 0 : fromelem.offsetHeight) - canvasTop;
  drawHorizArcArrow(ctx, midfrom, midto, y, angle, above);
}

    var canvasTop = 0;
function draw() { 
  var canvasdiv = document.getElementById("canvas");
  var spanboxdiv = document.getElementById("spanbox");
  var ctx = canvasdiv.getContext("2d");

  nodeset = generateNodeSet(); 
  linkset = generateLinks(nodeset);
  tofromseparation = 20;

  left = canvasdiv.offsetLeft - spanboxdiv.offsetLeft;
  canvasTop = canvasdiv.offsetTop - spanboxdiv.offsetTop; 
  for(var key in linkset) {  
    for (var i=0; i<linkset[key].length; i++) {  
      fromid = key; 
      toid = linkset[key][i]; 
      var above = (i%2==1);
      drawArrow(ctx,document.getElementById(fromid),document.getElementById(toid),above);
    } 
  } 
} 

</script> 

And you just need a call somewhere to the draw() function:

<body onload="draw();"> 

Then a canvas behind the set of spans.

<canvas style='border:1px solid red' id="canvas" width="800" height="7em"></canvas><br /> 
<div id="spanbox" style='float:left; position:absolute; top:75px; left:50px'>
<span id="T2">p50</span>
...
<span id="T3">p65</span> 
...
<span id="T34" ids="T2 T3">recruitment</span>
</div> 

Future modifications, as far as I can see:

  • Flattening the top of longer arrows
  • Refactoring to be able to draw non-horizontal arrows: add a new canvas for each?
  • Use a better routine to get the total offsets of the canvas and span elements.

[Edit Dec 2011: Fixed, thanks @Palo]

Hope that's as useful as it was fun.

Community
  • 1
  • 1
Phil H
  • 19,928
  • 7
  • 68
  • 105
  • Thanks, this looks quite impressive. And seems to be the answer that I was after. Too bad the bounty competition is over already. – Kaarel Mar 08 '09 at 16:34
  • 2
    Unfortunately a full time life didn't give me time to finish it before the bounty ended! Ah well. – Phil H Mar 08 '09 at 17:11
  • 2
    Thanks, I wish these kinds of little challenges came up more often. Nice mixture of geometry, learning APIs, and HTML frustration (canvas has no text rendering yet). And it makes something pretty! – Phil H Mar 24 '09 at 10:29
  • 1
    What browser would i use to see this in action? Chrome and ie9 beta don't draw any arrows - while both supporting HtmlCanvas. – Ian Boyd Feb 10 '11 at 16:10
  • Firefox 3 and 4 work at present. If you set the height of the canvas to something in pixels I think it will work in the others too. – Phil H Feb 11 '11 at 09:47
  • 1
    To make this work in all browsers you need to rename the top variable. Top is a reserved property of window. See http://www.w3schools.com/jsref/prop_win_top.asp. This needs to be renamed in functions draw and drawArrow. Plus you might need to specify height of canvas in pixels for some browsers as Phil already noted. – Palo Nov 03 '11 at 18:01
  • Just realised this wasn't working (heights in 'em' not a good idea in html tags). Fixed again. – Phil H Oct 29 '12 at 14:02
  • 2
    Great answer. Has anyone tried to using `D3.js` to draw the arrows? – Anthony Faull Nov 28 '14 at 12:48
24

You have a couple options: svg or canvas.

From the looks of it you don't need these arrows to have any particular mathematical form, you just need them to go between elements.

Try WireIt. Have a look at this WireIt Demo (which has been deprecated). It uses a canvas tag for each individual wire between the floating dialog divs, then sizes and positions each canvas element to give the appearance of a connecting line at just the right spot. You may have to implement an additional rotating arrowhead, unless you don't mind the arrows coming in to each element at the same angle.

Edit: the demo has been deprecated.

Edit: Ignore this answer, @Phil H nailed it

Community
  • 1
  • 1
Crescent Fresh
  • 115,249
  • 25
  • 154
  • 140
4

A great library for arrows is JointJS that is based on Raphael as shown above. With JointJS you can easily draw arrows with curves or vertices without any complicated stuff ;-)

var j34 = s3.joint(s4, uml.arrow).setVertices(["170 130", "250 120"]);

This defines an arrow 'j34' that connects two js items s3 with s4. Everything else can be read in the documentation of JointJS.

bluish
  • 26,356
  • 27
  • 122
  • 180
eraser
  • 101
  • 1
  • 5
2

If you don't need curved arrows, you could use absolutely positioned divs above or below the list. You could then use css to style those divs plus a couple of images that make up the arrow head. Below is an example using the icon set from the jQuery UI project (sorry about the long URL).

Here's the CSS to get things started:

<style>
 .below{
     border-bottom:1px solid #000;
     border-left:1px solid #000;
     border-right:1px solid #000;
 }
 .below span{
    background-position:0px -16px;
    top:-8px;
 }
 .above{
     border-top:1px solid #000;
     border-left:1px solid #000;
     border-right:1px solid #000;
 }
 .above span{
    background-position:-64px -16px;
    bottom:-8px;
 }

 .arrow{
    position:absolute;
    display:block;
    background-image:url(http://jquery-ui.googlecode.com/svn/trunk/themes/base/images/ui-icons_454545_256x240.png);
    width:16px;
    height:16px;
    margin:0;
    padding:0;
 }

.left{left:-8px;}

.right{right:-9px;}

</style>

Now we can start to assemble arrow divs. For instance, to style the arrow from "requires" to "promoter" in your example above, you could do left,bottom, and right borders on the div with and upward facing arrow graphic in the top left of the div.

<div class='below' style="position:absolute;top:30px;left:30px;width:100px;height:16px">
   <span class='arrow left'></span>
</div>

The inline styles would be need to be applied by script after you figured out the locations of the things you would need to connect. Let's say that your list looks like this:

<span id="promoter">Promoter</span><span>Something Else</span><span id="requires">Requires</span>

Then the following script will position your arrow:

<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.3/jquery.min.js"></script> 
<script>
$(function(){
 var promoterPos=$("#promoter").offset();
 var requiresPos=$("#requires").offset();
 $("<div class='below'><span class='arrow left'></span></div>")
 .css({position:"absolute",left:promoterPos.left,right:promoterPos.top+$("#promoter").height()})
 .width(requiresPos.left-promoterPos.left)
 .height(16)
 .appendTo("body");
});
</script>

Go ahead and paste the examples above into a blank html page. It's kind of neat.

bluish
  • 26,356
  • 27
  • 122
  • 180
Josh Bush
  • 2,708
  • 4
  • 24
  • 25
  • Well, my main question is: how do I figure out the locations of the things that need to be connected. The HTML file would only have ID attributes for these things. How to I map an ID to the screen location with Javascript? – Kaarel Feb 28 '09 at 20:07
  • You can use jQuery offset or position and width/height functions to determine that. (http://docs.jquery.com/CSS) – Josh Bush Feb 28 '09 at 20:13
  • Edited my answer to include an example using jQuery to find out the positions of the items to connect and drawing a line between them. – Josh Bush Feb 28 '09 at 21:57
1

You could try this JavaScript Vector Graphics Library - it's very clever stuff, hope it helps.

EDIT: As this link is dead, here is another link from Archive.org.

levininja
  • 3,118
  • 5
  • 26
  • 41
Kieron
  • 26,748
  • 16
  • 78
  • 122
  • 1
    +1 for Walter Zorn's drawing library ... it's perfect for this type of application (but don't try to use it for a web-based CAD system!). – Steve Moyer Feb 28 '09 at 16:09
  • -1 this link is no longer available, you should had detailed more on whats on that page – Quamis Nov 16 '12 at 07:29
  • 1
    This answer is nearly three years old, as I don't own the internet I can't guarantee the longevity of the links. However, a two second hunt around Archive.org found a version from the 20th Feb 2009...! – Kieron Nov 16 '12 at 09:02
1

I try to go with open web technologies wherever possible but the truth is that HTML & JavaScript (or jQuery) aren't the tools for this particular job (sad but true), especially as the diagrams you're drawing increase in complexity.

On the other hand, Flash was made for this. Significantly less ActionScript 3.0 code would be required to parse that XML, layout your text (with more control over fonts & super/subscripts) and render the curves (see the flash.display.Graphics class methods like curveTo). Overall you'll be looking at less code, better maintainability, fewer hacks, wider compatibility and more stable drawing libraries.

Good luck with the project.

bluish
  • 26,356
  • 27
  • 122
  • 180
Jaysen Marais
  • 3,956
  • 28
  • 44
  • And you don't even need to buy a full flash license to create the flash app - the free flex sdk is sufficient since all the drawing will be procedural anyway. – Simon Groenewolt Feb 28 '09 at 11:20
  • Adding a notice from 2021: Flash has now been deprecated for some time and is no longer supported by most browsers. – Software Engineer Feb 10 '21 at 09:12
1

As others have mentioned, Javascript and html are not good tools for this sort of thing.

John Resig wrote an implementation of Processing.org in JavaScript. It uses the canvas element, so it will work in modern versions of Firefox, but it will not work in all browsers. If you only care about Firefox, this would probably be the way to go.

You might be able to use SVG, but again, this is not supported in all browsers.

bluish
  • 26,356
  • 27
  • 122
  • 180
Buddy
  • 6,603
  • 1
  • 21
  • 16
0

You could get the curved arrow ends using a handful of position:absolute divs with background-image set to transparent GIFs... a set for beginning (top and bottom)... a bacground:repeat div for expandible middle, and another pair for the ends (top and bottom).

bluish
  • 26,356
  • 27
  • 122
  • 180
Scott Evernden
  • 39,136
  • 15
  • 78
  • 84
0

I needed a similar solution, and I was looking into RaphaelJS JavaScript Library. For example you can draw a straight arrow from (x1,y1) to (x2,y2) with:

Raphael.fn.arrow = function (x1, y1, x2, y2, size) {
  var angle = Math.atan2(x1-x2,y2-y1);
  angle = (angle / (2 * Math.PI)) * 360;
  var arrowPath = this.path(“M” + x2 + ” ” + y2 + ” L” + (x2 - size) + ” ” + (y2 - size) + ” L” + (x2 - size) + ” ” + (y2 + size) + ” L” + x2 + ” ” + y2 ).attr(“fill”,”black”).rotate((90+angle),x2,y2);
  var linePath = this.path(“M” + x1 + ” ” + y1 + ” L” + x2 + ” ” + y2);
  return [linePath,arrowPath];
}

I haven't figure out how to draw a curved arrow, but I'm sure it's possible.

bluish
  • 26,356
  • 27
  • 122
  • 180
Panagiotis Panagi
  • 9,927
  • 7
  • 55
  • 103
-1

You can use this library: just annotate your SVG lines with the ids of the source & target element. It uses MutationObserver to observe changes in the connected elements.

thSoft
  • 21,755
  • 5
  • 88
  • 103