Technology Blogs by SAP
Learn how to extend and personalize SAP applications. Follow the SAP technology blog for insights into SAP BTP, ABAP, SAP Analytics Cloud, SAP HANA, and more.
cancel
Showing results for 
Search instead for 
Did you mean: 
david_stocker
Advisor
Advisor
0 Kudos

This is part of a tutorial series on creating extension components for Design Studio.

Last time, we took out first look at text elements in D3 and the attributes that we need to position our text.  Now we'll work out how the positioning of our callouts.  In general, we are going to have to types of callouts available to designers; measure value callouts and guide line value callouts.

  • Measure Callout - This callout tracks the value of the measureVal property, or the  endAngleDeg property if useMeasures has been disabled.
  • Guide Line Callout - This callout pair tracks the measureMin and measureMax properties, or the startAngleDeg and endAngleDegMax if useMeasures has been disabled.

In the end, out sandbox html file will produce something like this:

Measure Callout Positioning

When the designer elects to enable measure callouts on the gauge, we want to have a couple of options as to placing the callouts.

  • The designer can select from a fixed number of vertical positions, along the horizontal centerline of the gauge.
  • The designer can elect to have the endpoint of the indicator needle, (or where it would be if the indicator is not enabled) anchored to the endpoint, or along the middle of the guide.  When anchored to the "endpoint", the callout shall be either right to left justified, as needed.

The positioning of the measure text callout is fairly straightforward.

Unless we are positioning on the endpoint, we are on the vertical center axis.  The Y axis translation offset of the text element is always the same Y axis translation as the main arc; offsetLeft.  We'll calculate the vertical position, based on a set of arbitrary vertical axis translation values. (see image, above).  If we are positioning at the endpoint, then we'll calculate the endpoint position with basic trigonometry.  The function to do this calculation is very simple:


function endPoints (lineLength, lineAngle){


  var endX = offsetLeft + (lineLength * Math.sin(lineAngle * (pi/180)));


  var endY = offsetDown - (lineLength * Math.cos(lineAngle * (pi/180)));


  return {x:endX, y:endY}


}



The flow chart below, lays out the X and Y axis offset translations, as well as the final vertical anchoring of the text, presuming the use of the SVG dominant baseline property.  Since we're using the dy attribute instead, this will translate as follows:


dominant-baseline: text-before-edge   =>   dy: 0em


dominant-baseline: text-after-edge      =>   dy: 1em



This translates into the following JavaScript:


if (measureTextPositionType == "endpoint"){


  measurePosition = endPoints (outerRad, endAngleDeg);


  measureTextPosition = ["start", "1em"];


  if ((measurePosition.x - offsetLeft) < 0){


  measureTextPosition[0] = "end";


  }


  if ((measurePosition.y - offsetDown) < 0){


  measureTextPosition[1] = "0em";


  }


}


else{


  if (measureTextPositionType == "top"){


  measurePosition = endPoints (outerRad, 0);


  measureTextPosition = ["middle", "-.15em"];


  //measureTextPosition = ["middle", "text-before-edge"];


  }


  else if (measureTextPositionType == "upperCentral"){


  measurePosition = endPoints (outerRad/2, 0);


  measureTextPosition = ["middle", "-.15em"];


  //measureTextPosition = ["middle", "text-before-edge"];


  }


  else if (measureTextPositionType == "upperIdeographic"){


  measurePosition = endPoints (1, 0);


  measureTextPosition = ["middle", "-.15em"];


  //measureTextPosition = ["middle", "text-before-edge"];


  }


  else if (measureTextPositionType == "lowerIdeographic"){


  measurePosition = endPoints (1, 180);


  measureTextPosition = ["middle", "1.1em"];


  //measureTextPosition = ["middle", "text-after-edge"];


  }


  else if (measureTextPositionType == "lowerCentral"){


  measurePosition = endPoints (outerRad/2, 180);


  measureTextPosition = ["middle", "1.1em"];


  //measureTextPosition = ["middle", "text-after-edge"];


  }


  else if (measureTextPositionType == "bottom"){


  measurePosition = endPoints (outerRad, 180);


  measureTextPosition = ["middle", "1.1em"];


  //measureTextPosition = ["middle", "text-after-edge"];


  }


}



Guide Line Positioning

If guide line callouts are enabled, we will create a pair of text callouts; one for start angle and one for end angle.  We want to be able to position these guide line callouts at either the endpoint of the guide line, or along the midpoint.  In both cases, we use the endpoint() function, above and simply use lineLength = outerRadius; or lineLength = outerRadius/2.

In general, we want to follow two basic rules:

  • The text-anchor property is set o that the text stays outside the gauge arc.  The gauge is always presumed to run clockwise.  This means that start angle value should be positioned to come "before" the start guide line angle.   And the end angle value should come "after" the end angle value.
  • The dy value (either 0em or .8em) is set so that the text should be inside the gauge ring.

The two images below show the positioning rules for start angle callouts and end angle callouts:

Start



End

The flowchart for the guide positioning algorithm needed for the above pictures looks like the following:

This flow translates into the following JavaScript function:


function textPositioning (x, y, isStart){


  var relativeOffsetX = x - offsetLeft;


  var relativeOffsetY = y - offsetDown;




  if (isStart == undefined){


  isStart = false;


  }


  var dominantBaseline = null;


  var textAnchor = null;


  if ((relativeOffsetX >= 0) && (relativeOffsetY >= 0)){


  //Lower Right Quadrant


  // Both middle and enf have a negative dominant baseline


  if (isStart == true){


  textAnchor = "start";


  dominantBaseline = "0em";


  } else {


  textAnchor = "end";


  dominantBaseline = ".8em";


  }



  } else if ((relativeOffsetX >= 0) && (relativeOffsetY < 0)){


  //Upper Right Quadrant


  if (isStart == true){


  textAnchor = "end";


  dominantBaseline = "0em";


  } else {


  textAnchor = "start";


  dominantBaseline = ".8em";


  }


  }


  else if ((relativeOffsetX < 0) && (relativeOffsetY < 0)){


  //Upper Left Quadrant


  if (isStart == true){


  textAnchor = "end";


  dominantBaseline = ".8em";


  } else {


  textAnchor = "start";


  dominantBaseline = "0em";


  }


  } else {


  //Lower Left Quadrant


  if (isStart == true){


  textAnchor = "start";


  dominantBaseline = ".8em";


  } else {


  textAnchor = "end";


  dominantBaseline = "0em";


  }


  }




  return [textAnchor, dominantBaseline]



}

Putting it all together, into our sandbox html file, we get the following code, which displays this blog post.  Next time, we'll migrate this new code into our component. You can experiment with different measure text positions, by altering the value of measureTextPositionType, on line 205.


<!DOCTYPE html>


<html>


  <head>


  <meta http-equiv='X-UA-Compatible' content='IE=edge' />


  <title>Part 7</title>



  <div id='content'></div>


  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.min.js" charset="utf-8"></script>


  <!-- <script src="file://d3/d3.js" charset="utf-8"></script>-->


  <script>


  var vis = d3.select("#content").append("svg:svg").attr("width", "100%").attr("height", "100%");



  var pi = Math.PI;



  //Viz definitiions


  var innerRad = 0;


  //var outerRad = 70;


  var width = 400;


  var height = 400;


  var startAngleDeg = -45;


  var endAngleDeg = 45;


  var colorCode = "red";




  //Outer Dimensions & Positioning


  var paddingTop = 10;


  var paddingBottom = 10;


  var paddingLeft = 10;


  var paddingRight = 10;



  //The total size of the component is calculated from its parts



  // Find the larger left/right padding


  var lrPadding = paddingLeft + paddingRight;


  var tbPadding = paddingTop + paddingBottom;


  var maxPadding = lrPadding;


  if (maxPadding < tbPadding){


  maxPadding = tbPadding


  }



  var outerRad = (width - 2*(maxPadding))/2;


  //var width = (outerRad * 2) + paddingLeft + paddingRight;


  //var height = (outerRad * 2) + paddingTop + paddingBottom;



  //The offset will determine where the center of the arc shall be


  var offsetLeft = outerRad + paddingLeft;


  var offsetDown = outerRad + paddingTop;



  //Don't let the arc have a negative length


  if (endAngleDeg < startAngleDeg){


  endAngleDeg = startAngleDeg;


  alert("End angle may not be less than start angle!");


  }




  var arcDef = d3.svg.arc()


  .innerRadius(innerRad)


  .outerRadius(outerRad);



  var guageArc = vis.append("path")


     .datum({endAngle: startAngleDeg * (pi/180), startAngle: startAngleDeg * (pi/180)})


     .style("fill", "orange")


     .attr("transform", "translate(" + offsetLeft + "," + offsetDown + ")")


     .attr("d", arcDef);







  ///////////////////////////////////////////


  //Lets build a border ring around the gauge


  ///////////////////////////////////////////


  //var visRing = d3.select("#content").append("svg:svg").attr("width", "100%").attr("height", "100%");


  var ringThickness = 2;


  var ringOuterRad = outerRad + ringThickness;  //Outer ring starts at the outer radius of the inner arc


  var ringColorCode = "black";


  var ringStartAngleDeg = 0;


  var ringEndAngleDeg = 360;



  //Don't let the arc have a negative length


  if (ringEndAngleDeg < ringStartAngleDeg){


  ringEndAngleDeg = ringStartAngleDeg;


  alert("End angle of outer ring may not be less than start angle!");


  }


  var ringArcDefinition = d3.svg.arc()


  .innerRadius(outerRad)


  .outerRadius(ringOuterRad)


  .startAngle(ringStartAngleDeg * (pi/180)) //converting from degs to radians


  .endAngle(ringEndAngleDeg * (pi/180)) //converting from degs to radians



  var ringArc = vis


  .append("path")


  .attr("d", ringArcDefinition)


  .attr("fill", ringColorCode)


  .attr("transform", "translate(" + offsetLeft + "," + offsetDown + ")");





  ///////////////////////////////////////////


  //Lets build a the start and end lines


  ///////////////////////////////////////////


  var bracketThickness = 2;


  var lineData = [endPoints (outerRad, startAngleDeg), {x:offsetLeft, y:offsetDown}, endPoints (outerRad, endAngleDeg)];


  var visStartBracket = d3.select("#content").append("svg:svg").attr("width", "100%").attr("height", "100%");


  var lineFunction = d3.svg.line()


  .x(function(d) { return d.x; })


  .y(function(d) { return d.y; })


  .interpolate("linear");



  var borderLines = vis


  .attr("width", width).attr("height", height) // Added height and width so line is visible


  .append("path")


  .attr("stroke", ringColorCode)


  .attr("stroke-width", bracketThickness)


  .attr("fill", "none");



  //Helper function


  function endPoints (lineLength, lineAngle){


  var endX = offsetLeft + (lineLength * Math.sin(lineAngle * (pi/180)));


  var endY = offsetDown - (lineLength * Math.cos(lineAngle * (pi/180)));


  return {x:endX, y:endY}


  }



  ///////////////////////////////////////////


  //Lets add the indicator needle


  ///////////////////////////////////////////



  //needleWaypoints is defined with positive y axis being up


  var needleWaypoints = [{x: 0,y: 100}, {x: 10,y: 0}, {x: 0,y: -10}, {x: -10,y: 0}, {x: 0,y: 100}]



  //we need to invert the y-axis and scale the indicator to the gauge.


  //  If Y = 100, then that is 100% of outer radius.  So of Y = 100 and outerRad = 70, then the scaled Y will be 70.


  var needleFunction = d3.svg.line()


  .x(function(d) { return (d.x)*(outerRad/100); })


  .y(function(d) { return -1*(d.y)*(outerRad/100); })


  .interpolate("linear");



  var needle = vis


  .append("g")


      .attr("transform", "translate(" + offsetLeft + "," + offsetDown + ")")


  .append("path")


      .attr("d", needleFunction(needleWaypoints))


  .attr("stroke", ringColorCode)


  .attr("stroke-width", bracketThickness)


  .attr("fill", ringColorCode)


  .attr("transform", "rotate(" + startAngleDeg + ")");







  ///////////////////////////////////////////


  //Lets add animations


  ///////////////////////////////////////////



  var delayNeedle = 500;


  var durationNeedle = 10000;


  var easeNeedle = "elastic"; //https://github.com/mbostock/d3/wiki/Transitions#d3_ease



  var delayBorderLines = 500;


  var durationBorderLines = 1000;


  var easeBorderLines = "linear"; //https://github.com/mbostock/d3/wiki/Transitions#d3_ease



  var durationArc = 5000;


  var easeArc= "linear"; //https://github.com/mbostock/d3/wiki/Transitions#d3_ease




  //Arcs are in radians, but rotation transformations are in degrees.  Kudos to D3 for consistency


  needle.transition()


  .attr("transform", "rotate(" + endAngleDeg + ")")


  .duration(durationNeedle)


  .delay(delayNeedle)


  .ease(easeNeedle);



  borderLines.transition()


  .attr("d", lineFunction(lineData))


  .duration(durationBorderLines)


  .delay(delayBorderLines)


  .ease(easeBorderLines);




  var arcStepDef = d3.svg.arc()


  .innerRadius(innerRad)


  .outerRadius(outerRad);




  guageArc.transition()


  .duration(durationArc)


        .call(arcTween, endAngleDeg * (pi/180));




  //This blog post explains using attrTween for arcs: http://bl.ocks.org/mbostock/5100636


  // Function adapted from this example


  // Creates a tween on the specified transition's "d" attribute, transitioning


  // any selected arcs from their current angle to the specified new angle.


  function arcTween(transition, newAngle) {


  transition.attrTween("d", function(d) {


     var interpolate = d3.interpolate(d.endAngle, newAngle);


     return function(t) {


  d.endAngle = interpolate(t);


  return arcDef(d);


  };


  });


  }






  ///////////////////////////////////////////


  //Lets add a legend


  ///////////////////////////////////////////



  //https://developer.mozilla.org/en/docs/Web/SVG/Attribute/text-anchor



  var guidePositioning = "end";  //"end" and "midpoint"


  var measureTextPositionType = "upperCentral";


  var drawGuideText = true;


  var drawMeasureText = true;



  //Measure Text Positioning


  if (drawMeasureText == true){


  var measurePosition = {};


  var measureTextPosition = {};


  if (measureTextPositionType == "endpoint"){


  measurePosition = endPoints (outerRad, endAngleDeg);


  measureTextPosition = ["start", "1em"];


  if ((measurePosition.x - offsetLeft) < 0){


  measureTextPosition[0] = "end";


  }


  if ((measurePosition.y - offsetDown) < 0){


  measureTextPosition[1] = "0em";


  }


  }


  else{


  // Hack Alert!


  //As of now, MS browsers don"t support the dominant baseline SVG property.


  //  Using the dy property with a Xem offset is the hackish workaround


  // https://msdn.microsoft.com/en-us/library/gg558060(v=vs.85).aspx


  if (measureTextPositionType == "top"){


  measurePosition = endPoints (outerRad, 0);


  measureTextPosition = ["middle", "-.15em"];


  //measureTextPosition = ["middle", "text-before-edge"];


  }


  else if (measureTextPositionType == "upperCentral"){


  measurePosition = endPoints (outerRad/2, 0);


  measureTextPosition = ["middle", "-.15em"];


  //measureTextPosition = ["middle", "text-before-edge"];


  }


  else if (measureTextPositionType == "upperIdeographic"){


  measurePosition = endPoints (1, 0);


  measureTextPosition = ["middle", "-.15em"];


  //measureTextPosition = ["middle", "text-before-edge"];


  }


  else if (measureTextPositionType == "lowerIdeographic"){


  measurePosition = endPoints (1, 180);


  measureTextPosition = ["middle", "1.1em"];


  //measureTextPosition = ["middle", "text-after-edge"];


  }


  else if (measureTextPositionType == "lowerCentral"){


  measurePosition = endPoints (outerRad/2, 180);


  measureTextPosition = ["middle", "1.1em"];


  //measureTextPosition = ["middle", "text-after-edge"];


  }


  else if (measureTextPositionType == "bottom"){


  measurePosition = endPoints (outerRad, 180);


  measureTextPosition = ["middle", "1.1em"];


  //measureTextPosition = ["middle", "text-after-edge"];


  }


  }




  vis.append("text")


  .attr("transform", "translate(" + measurePosition.x+ "," + measurePosition.y+ ")")


  .text("measureText")


  .attr("text-anchor", measureTextPosition[0])


  //.attr("dominant-baseline", measureTextPosition[1]);


  .attr("dy", measureTextPosition[1]);


  }



  //Guide Positioning


  if (drawGuideText == true){


  var guidePositionStart = {};


  var guidePositionEnd = {};


  var isMiddleCO = false;


  if (guidePositioning == "end"){


  guidePositionStart = endPoints (outerRad, startAngleDeg);


  guidePositionEnd = endPoints (outerRad, endAngleDeg);


  }


  else {


  guidePositionStart = endPoints (outerRad/2, startAngleDeg);


  guidePositionEnd = endPoints (outerRad/2, endAngleDeg);


  }


  var guideTextPositionStart = textPositioning (guidePositionStart.x, guidePositionStart.y, true);


  var guideTextPositionEnd= textPositioning (guidePositionEnd.x, guidePositionEnd.y);




  //Start Text


  vis.append("text")


  .attr("transform", "translate(" + guidePositionStart.x + "," + guidePositionStart.y + ")")


  .text("startText")


  .attr("text-anchor", guideTextPositionStart[0])


  //.attr("dominant-baseline", guideTextPositionStart[1]);


  .attr("dy", guideTextPositionStart[1]);




  //End Text


  vis.append("text")


  .attr("transform", "translate(" + guidePositionEnd.x + "," + guidePositionEnd.y + ")")


  .text("endText")


  //.attr("text-anchor", "start")


  .attr("text-anchor", guideTextPositionEnd[0])


  //.attr("dominant-baseline", guideTextPositionEnd[1]);


  .attr("dy", guideTextPositionEnd[1]);


  }








  // Helper function to determine the vertical alignment (called 'dominant-baseline') and horizontal alignment (called ' text-anchor')


  // In essence, this function tries to find a readable position for the text, so that it lies ourside the main arc, no matter the current


  // start and end points:


  // If x is to the left of the gauge's centerline, then the text should be anchored to the left of x.  Otherwise to the right


  // If y id below the centerline, then the text should be below y.  Otherwise above


  // dominant-baseline: http://bl.ocks.org/eweitnauer/7325338


  // text-anchor: https://developer.mozilla.org/en/docs/Web/SVG/Attribute/text-anchor


  function textPositioning (x, y, isStart){


  var relativeOffsetX = x - offsetLeft;


  var relativeOffsetY = y - offsetDown;




  if (isStart == undefined){


  isStart = false;


  }




  var dominantBaseline = null;


  var textAnchor = null;


  if ((relativeOffsetX >= 0) && (relativeOffsetY >= 0)){


  //Lower Right Quadrant


  // Both middle and enf have a negative dominant baseline


  if (isStart == true){


  textAnchor = "start";


  dominantBaseline = "0em";


  } else {


  textAnchor = "end";


  dominantBaseline = ".8em";


  }



  } else if ((relativeOffsetX >= 0) && (relativeOffsetY < 0)){


  //Upper Right Quadrant


  if (isStart == true){


  textAnchor = "end";


  dominantBaseline = "0em";


  } else {


  textAnchor = "start";


  dominantBaseline = ".8em";


  }


  }


  else if ((relativeOffsetX < 0) && (relativeOffsetY < 0)){


  //Upper Left Quadrant


  if (isStart == true){


  textAnchor = "end";


  dominantBaseline = ".8em";


  } else {


  textAnchor = "start";


  dominantBaseline = "0em";


  }


  } else {


  //Lower Left Quadrant


  if (isStart == true){


  textAnchor = "start";


  dominantBaseline = ".8em";


  } else {


  textAnchor = "end";


  dominantBaseline = "0em";


  }


  }



  return [textAnchor, dominantBaseline]


  }





  </script>


    </head>


  <body class='sapUiBody'>


  <div id='content'></div>


  </body>


</html>