Second Life of a Hungarian SharePoint Geek

March 13, 2014

Creating a Set of Concentric Circles with Texts Using HTML5 and JavaScript

Filed under: HTML5, JavaScript — Tags: , — Peter Holpar @ 23:48

Recently one of our customers wanted to have a webpage with an illustration including concentric circles / rings with clickable text on the arcs. When the user clicks on the texts we should display related information from SharePoint lists, but it is irrelevant to the main topic of this post. Our first idea was to create a static image map, but the customer informed us, that the texts and the arrangement might change several times, so I preferred some kind of dynamic solution and decided to apply HTML5 and JavaScript.

In this post I would like to introduce the results of my work and share the code with the community.

First I created a few JavaScript “classes” describing entities like a circle / ring (with attributes like center, inner and outer radius) and arc (with attributes like start and end angle, text and text alignment). Then I defined helper functions that enable creation of rings or appending arcs next to each other. Finally I wrote the necessary methods to draw the arcs, to write text along the arcs (supporting multiline text, various alignments like align to left, right, center or justify, and support to write text top-down at the upper part of the circle and bottom-up at the lower part), and to detect if the mouse is over an arc (and if it is, highlight the arc), and to react on mouse click.

Note: Multiline text support in this version is limited to explicit line breaks (‘\n’ in text), no automatic word wrapping.

The algorithms in the solution require a solid understanding of trigonometry. Fortunately, I found several resources on the web (see the source code at the bottom for related URLs) that helped me in the first steps.

The screenshot below illustrates a sample including a few rings and arcs for each ring. Note the various text directions (at the top and bottom parts of the circles, like the directions of “Test 2” versus direction of “Test 3”) and text alignments.

Note: In this implementation the circles and arcs are hardcoded, but you are free to get the data to initialization from a background system, like SharePoint lists or so.

 

image

When a mouse hovers over an arc, the arc is highlighted with an other background color.

 

image

Note: Using a significantly different background color for highlighting does not work well (you can try it for example using red instead of grey, like illustrated below), as after the mouse moves out from the area and the original background color is applied again, the borders of the arcs remain “dirty”.

image

Finally, the arc reacts on mouse click with an alert including the text of the arc, however you can extend the solution with more advanced interaction, like calling REST interfaces to display the response dynamically.

image

Below I publish the whole source code of this sample solution.

  1. <!DOCTYPE HTML>
  2. <html>
  3. <head>
  4.     <style>
  5.         body
  6.         {
  7.             margin: 0px;
  8.             padding: 0px;
  9.         }
  10.     </style>
  11. </head>
  12. <body>
  13.     <canvas id="myCanvas" width="800" height="800"></canvas>
  14.     <script>
  15.         // http://www.html5canvastutorials.com/tutorials/html5-canvas-arcs/
  16.         // http://www.html5canvastutorials.com/labs/html5-canvas-text-along-arc-path/
  17.  
  18.         var canvas = document.getElementById('myCanvas');
  19.  
  20.         canvas.addEventListener('mousemove', track_mouse, false);
  21.         canvas.addEventListener('click', click_mouse, false);
  22.  
  23.         TextAlignment = {
  24.             Left : 0,
  25.             Center : 1,
  26.             Right : 2,
  27.             Justify : 3
  28.         }
  29.  
  30.         function point (x, y) {
  31.             this.x = x;
  32.             this.y = y;
  33.         }
  34.  
  35.         function ring (center, minRadius, maxRadius) {
  36.             this.center = center;
  37.             this.minRadius = minRadius;
  38.             this.maxRadius = maxRadius;
  39.         }
  40.  
  41.         function arc (ring, startAngle, endAngle, text, alignment) {
  42.             this.ring = ring;
  43.             this.startAngle = startAngle;
  44.             this.endAngle = endAngle;
  45.             this.text = text;
  46.             this.alignment = (alignment != undefined) ? alignment : TextAlignment.Center;
  47.             this.createArcAfter = function (angle, text) {
  48.                 return new arc(this.ring, this.endAngle, this.endAngle + angle, text);
  49.             };
  50.             this.createArcAfterUpTo = function (upToArc, text) {
  51.                 return new arc(this.ring, this.endAngle, upToArc.startAngle, text);
  52.             };
  53.             this.isInside = function (pos) {
  54.                 // http://stackoverflow.com/questions/6270785/how-to-determine-whether-a-point-x-y-is-contained-within-an-arc-section-of-a-c
  55.                 // Angle = arctan(y/x); Radius = sqrt(x * x + y * y);
  56.                 var result = false;
  57.                 var radius = Trig.distanceBetween2Points(pos, this.ring.center);
  58.                 // we calculate atan only if the radius is OK
  59.                 if ((radius >= this.ring.minRadius) && (radius <= this.ring.maxRadius)) {
  60.                     var angle = Trig.angleBetween2Points(this.ring.center, pos);
  61.  
  62.                     var a = (angle < 0) ? angle + 2 * Math.PI : angle;
  63.                     var sa = this.startAngle;
  64.                     var ea = this.endAngle;
  65.  
  66.                     if (ea > 2 * Math.PI) {
  67.                         sa -= 2 * Math.PI;
  68.                         ea -= 2 * Math.PI;
  69.                     }
  70.  
  71.                     if (sa > ea) {
  72.                         sa -= 2 * Math.PI;
  73.                     }
  74.  
  75.                     if ((a >= sa) && (a <= ea)) {
  76.                         result = true;
  77.                     }
  78.                 }
  79.                 return result;
  80.             };
  81.  
  82.             this.higlightIfInside = function (pos) {
  83.                 if (this.isInside(pos)) {
  84.                     arc.isHighlighted = this;
  85.                     drawArc(this, true);
  86.                 }
  87.             };
  88.  
  89.             this.doTask = function (pos) {
  90.                 if (this.isInside(pos)) {
  91.                     alert(this.text);
  92.                 }
  93.             };
  94.  
  95.             if (arc.arcs == undefined) {
  96.                 arc.arcs = new Array();
  97.             }
  98.             arc.arcs.push(this);
  99.         }
  100.  
  101.         arc.lastHighlighted = null;
  102.         arc.isHighlighted = null;
  103.  
  104.         arc.drawAll = function () {
  105.             arc.arcs.forEach(function (a) {
  106.                 drawArc(a);
  107.             });
  108.         }
  109.         
  110.         arc.checkMousePos = function (pos) {
  111.             arc.lastHighlighted = arc.isHighlighted;
  112.             arc.isHighlighted = null;
  113.             arc.arcs.forEach(function (a) {
  114.                 a.higlightIfInside(pos);
  115.             });
  116.             if ((arc.lastHighlighted != null) && (arc.isHighlighted != arc.lastHighlighted)) {
  117.                 drawArc(arc.lastHighlighted);
  118.             }
  119.  
  120.             // set cursor according to the highlight status
  121.             canvas.style.cursor = (arc.isHighlighted != null) ? 'pointer' : 'default';
  122.         }
  123.  
  124.         arc.doTasks = function (pos) {
  125.             arc.arcs.forEach(function (a) {
  126.                 a.doTask(pos);
  127.             });
  128.         }
  129.  
  130.         // http://www.tricedesigns.com/2012/01/04/sketching-with-html5-canvas-and-brush-images/
  131.         var Trig = {
  132.             distanceBetween2Points: function (point1, point2) {
  133.                 var dx = point2.x – point1.x;
  134.                 var dy = point2.y – point1.y;
  135.                 return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
  136.             },
  137.             angleBetween2Points: function (point1, point2) {
  138.                 var dx = point2.x – point1.x;
  139.                 var dy = point2.y – point1.y;
  140.                 return Math.atan2(dy, dx);
  141.             },
  142.             angleDiff: function (startAngle, endAngle) {
  143.                 var angleDiff = (startAngle – endAngle);
  144.                 angleDiff += (angleDiff > Math.PI) ? -2 * Math.PI : (angleDiff < -Math.PI) ? 2 * Math.PI : 0
  145.                 return angleDiff;
  146.             }
  147.         }
  148.      
  149.         var center = new point(canvas.width / 2, canvas.height / 2);
  150.  
  151.         var r1 = new ring(center, 100, 150);
  152.         var arc1 = new arc(r1, 1.4 * Math.PI, 1.9 * Math.PI, "It's a test");
  153.         var arc1_2 = arc1.createArcAfter(0.6 * Math.PI, "This is a\nmultiline text");
  154.         var arc1_3 = arc1_2.createArcAfterUpTo(arc1, "Inner test");
  155.  
  156.         var r2 = new ring(center, 160, 210);
  157.         var arc2 = new arc(r2, 0 * Math.PI, 0.6 * Math.PI, "Test messgae");
  158.         var arc2_1 = arc2.createArcAfter(0.3 * Math.PI, "Test 2");
  159.         var arc2_2 = arc2_1.createArcAfter(0.3 * Math.PI, "Test 3");
  160.  
  161.         var r3 = new ring(center, 220, 270);
  162.         var arc3 = new arc(r3, 0 * Math.PI, 1 * Math.PI, "Left aligned text", TextAlignment.Left);
  163.         var arc3_1 = new arc(r3, 1 * Math.PI, 1.5 * Math.PI, "Right aligned text", TextAlignment.Right);
  164.         var arc3_2 = new arc(r3, 1.5 * Math.PI, 2 * Math.PI, "Justified text", TextAlignment.Justify);
  165.  
  166.         var context = canvas.getContext('2d');
  167.  
  168.         arc.drawAll();
  169.  
  170.         function drawArc(arc, isHighlighted) {
  171.             var gapsAtEdgeAngle = Math.PI / 400;
  172.             var isCounterClockwise = false;
  173.  
  174.             var startAngle = arc.startAngle + gapsAtEdgeAngle;
  175.             var endAngle = arc.endAngle – gapsAtEdgeAngle;
  176.             context.beginPath();
  177.             var radAvg = (arc.ring.maxRadius + arc.ring.minRadius) / 2;
  178.             context.arc(arc.ring.center.x, arc.ring.center.y, radAvg, startAngle, endAngle, isCounterClockwise);
  179.             context.lineWidth = arc.ring.maxRadius – arc.ring.minRadius;
  180.  
  181.             // line color
  182.             context.strokeStyle = isHighlighted ? 'grey' : 'lightgrey';
  183.             context.stroke();
  184.  
  185.             drawTextAlongArc(arc.text, center, radAvg, startAngle, endAngle, arc.alignment);
  186.         }
  187.  
  188.         function drawTextAlongArc(text, center, radius, startAngle, endAngle, alignment) {
  189.             var fontSize = 12;
  190.             var lineSpacing = 4;
  191.             var lines = text.split('\n');
  192.             var lineCount = lines.length;
  193.  
  194.             radius = radius + (lineCount – 1) / 2 * (fontSize + lineSpacing)
  195.  
  196.             lines.forEach(function (line) {
  197.                 drawLineAlongArc(context, line, center, radius, startAngle, endAngle, fontSize, alignment);
  198.                 radius -= (fontSize + lineSpacing);
  199.             });           
  200.         }
  201.  
  202.         function drawLineAlongArc(context, str, center, radius, startAngle, endAngle, fontSize, alignment) {
  203.             var len = str.length, s;
  204.             context.save();
  205.  
  206.             context.font = fontSize + 'pt Calibri';
  207.             context.textAlign = 'center';
  208.             context.fillStyle = 'black';
  209.  
  210.             // check if the arc is more at the top or at the bottom part of the ring
  211.             var upperPart = ((startAngle + endAngle) / 2) > Math.PI;
  212.  
  213.             // reverse the aligment direction if the arc is at the bottom
  214.             // Center and Justify is neutral in this sence
  215.             if (!upperPart) {
  216.                 if (alignment == TextAlignment.Left) {
  217.                     alignment = TextAlignment.Right;
  218.                 }
  219.                 else if (alignment == TextAlignment.Right) {
  220.                     alignment = TextAlignment.Left;
  221.                 }
  222.             }
  223.  
  224.             //var metrics = context.measureText(str);
  225.             var metrics = context.measureText(str.replace(/./gi, 'W'));
  226.             var textAngle = metrics.width / (radius – fontSize / 2);
  227.  
  228.             var gapsAtEdgeAngle = Math.PI / 80;
  229.  
  230.             if (alignment == TextAlignment.Left) {
  231.                 startAngle += gapsAtEdgeAngle;
  232.                 endAngle = startAngle + textAngle;
  233.             }
  234.             else if (alignment == TextAlignment.Center) {
  235.                 var ad = (Trig.angleDiff(endAngle, startAngle) – textAngle) / 2;
  236.                 startAngle += ad;
  237.                 endAngle -= ad;
  238.             }
  239.             else if (alignment == TextAlignment.Right) {
  240.                 endAngle -= gapsAtEdgeAngle;
  241.                 startAngle = endAngle – textAngle;
  242.             }
  243.             else if (alignment == TextAlignment.Justify) {
  244.                 startAngle += gapsAtEdgeAngle;
  245.                 endAngle -= gapsAtEdgeAngle;
  246.             }
  247.             else {
  248.                 // alignmet not supported
  249.                 // show some kind of warning
  250.                 // or fallback to default?
  251.             }
  252.  
  253.             // calculate text height and adjust radius according to font size
  254.             if (upperPart) {
  255.                 // if it is in the upper part, we have to change the orientation as well -> multiply by -1
  256.                 radius = -1 * (radius – fontSize / 2);
  257.             }
  258.             else {
  259.                 radius += fontSize / 2; //*
  260.             }
  261.  
  262.             context.translate(center.x, center.y);
  263.  
  264.             var angleStep = Trig.angleDiff(endAngle, startAngle) / len;
  265.  
  266.             if (upperPart) {
  267.                 angleStep *= -1;
  268.                 context.rotate(startAngle + Math.PI / 2);
  269.             }
  270.             else {
  271.                 context.rotate(endAngle – Math.PI / 2);
  272.             }
  273.  
  274.             context.rotate(angleStep / 2);
  275.  
  276.             for (var n = 0; n < len; n++) {
  277.                 context.rotate(-angleStep);
  278.                 context.save();
  279.                 context.translate(0, radius);
  280.                 s = str[n];
  281.                 context.fillText(s, 0, 0);
  282.                 context.restore();
  283.             }
  284.             context.restore();
  285.         }
  286.  
  287.  
  288.         function track_mouse(e) {
  289.             var target = e.currentTarget;
  290.             var mousePos = getMousePos(target, e);
  291.  
  292.             arc.checkMousePos(mousePos);
  293.         }
  294.  
  295.         function click_mouse(e) {
  296.             var target = e.currentTarget;
  297.             var mousePos = getMousePos(target, e);
  298.  
  299.             arc.doTasks(mousePos);
  300.         }
  301.  
  302.         function getMousePos(canvas, evt) {
  303.             var rect = canvas.getBoundingClientRect();
  304.             return {
  305.                 x: evt.clientX – rect.left,
  306.                 y: evt.clientY – rect.top
  307.             };
  308.         }
  309.     </script>
  310. </body>
  311. </html>

Any comments and ideas to further enhancements are welcome.

Leave a Comment »

No comments yet.

RSS feed for comments on this post. TrackBack URI

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Create a free website or blog at WordPress.com.

%d bloggers like this: