Thursday, December 16, 2021

Map Compasses (Part 8): Radial Text

Welcome back to my blog series on procedurally-generated map compasses!  I've been implementing the Compass Design Language (CDL) and an interpreter for the language.  I've been measuring my progress (on the right) against this compass (on the left):

I've made good progress on the graphic elements of the compass, and now I'll tackle the labels.

Like the triangles, hash marks and compass points, the text in this example is a radial element that appears at intervals around the circle of the compass.  Unlike the previous radial elements, this element differs at every place, so our command will need to include a list of texts, one for each location around the compass.
RTEXT(start, repeats, font, size, color, style, weight, texts)
Start and repeat are the usual parameters for a radial element that defines where to start and how many repeats to place around the compass.  Font, size, color, and style are parameters that will define the font to use and its characteristics (e.g., italic, bold, etc.).  The final element is the list of texts to use, one for each repeat.  Most of these parameters are similar to what I've implemented in the parser for previous commands, but I've never tried to parse a list in Nearley so it will be interesting to figure that out.

To start with, I'll use brackets to delimit the list, and this will be a list of double-quoted strings:
# DQLIST
# A list of double-quoted strings, possibly empty
dqList -> "[" WS dqElements WS "]"
The elements can be nothing, a single double-quoted string, or a single double-quoted string followed by a comma and then more elements.  
# DQLIST
# A list of double-quoted strings, possibly empty
dqList -> "[" WS dqElements WS "]"
dqElements -> null
	      | dqstring 
	      | dqstring WS "," WS dqElements
You might (if you've taken any classes on grammars) have been discourage from writing rules like that last clause because it does “right recursion" -- that is, the last element in that rule loops back to the rule itself.  That causes problems in some grammar parsers, but it turns out that Nearley is fine with this.

The grammar above recognizes the list of texts but doesn't return the most useful values.  I'll fix that with some post-processing.
# DQLIST
# A list of double-quoted strings, possibly empty
dqList -> "[" WS dqElements WS "]" {% data => data[2] %}
dqElements -> null {% data => [] %}
	      | dqstring {% data => data %}
	      | dqstring WS "," WS dqElements {% data => [data[0], ...data[4]] %}
Mostly this should be familiar from previous blog entries -- the post-processing for all except the last clause just removes an unnecessary level of arrays.  The last clause is a little more interesting.  The three dots I use there is the spread operator, and essentially what it does is stick in the contents of an array.  (It “spreads" out the array so to speak.)  In this case, I use it to concatenate together the first element of the list (data[0]) and the list of all the remaining elements (data[4]).

With that defined we can create the rule for RTEXT:
# Radial text
# RTEXT(start, repeats, font, size, color, style, weight, texts)
rtextElement -> "RTEXT"i WS "(" WS decimal WS "," WS decimal WS "," WS dqstring WS ","
	     		        WS decimal WS "," WS dqstring WS "," WS decimal WS ","
				WS dqstring WS "," WS dqList WS ")"
                 {% data => ({op: "RTEXT", start: data[4], repeats: data[8], font: data[12],
                              size: data[16], color: data[20], style: data[24], 
                              weight: data[28], texts: data[32]}) %}
Now I add RTEXT to the interpreter in the usual way and create a function Draw.rtext that will do the work of drawing the text.

Perhaps not unsurprisingly, text is added to SVG by appending an SVG element, i.e., in D3 something like this:
    svg.append('text')
	  .attr('x', x)
	  .attr('y', y)
	  .style('font-family',font)
	  .style('fill', color)
	  .style('font-size', fontSize)
	  .style('font-style', fontStyle)
          .style('font-weight', fontWeight)
	  .text(text);
There are many possible attributes and styles that can be applied to SVG text elements but the above covers the ones we'll be using.

Let me start the implementation of rtext() by putting some text at noon.
function rtext(svg, center, radius, startAngle, repeats, angle, iteration, op) {
    let x = center[0];
    let y = center[1]-radius;
    svg.append('text')
	.attr('x', x)
	.attr('y', y)
	.style('font-family', op.font)
	.style('fill', op.color)
	.style('font-size', op.size)
	.style('font-style', op.style)
	.style('font-weight', op.weight)
	.text(op.texts[0]);
};
That results in this:
That worked, but it has placed the text so that the lower-left-hand corner of the text box is at the specified [x,y] location.  I'm okay with having the bottom of the text at the [x,y] location (or at least I think I am, that might change) but I think I'd prefer that the text be centered on that point.  To do this, SVG has a text-anchor attribute which I can set to “middle".
Now let's move on to the stickier issue of placing text somewhere other than 12.  I'd like to use the same “draw at 12 and rotate to the correct position" strategy I've used for most of the other radial elements, but text is obviously not a polygon that I can go through and rotate the points with math.  The good news is that SVG itself has a method for rotating text (and many other elements).  

SVG rotate is part of the SVG transform attribute, which also offers other transformations like scaling and translation (move).  The syntax for a rotate transformation is simple:
rotate(degrees, x, y)

This rotates an object by degrees around the point [x, y].  Let me add that to the rtext function:

function rtext(svg, center, radius, startAngle, repeats, angle, iteration, op) {
    let x = center[0];
    let y = center[1]-radius;
    svg.append('text')
	.attr('x', x)
	.attr('y', y)
	.style('font-family', op.font)
	.style('fill', op.color)
	.style('font-size', op.size)
	.style('font-style', op.style)
	.style('font-weight', op.weight)
	.style('text-anchor', 'middle')
	.attr('transform', 'rotate('+rad2degrees(angle-Math.PI/2)+','+center[0]+','+center[1]+')')
	.text(op.texts[0]);
};
Notice I needed a helper function “rad2degrees" because my angles are in radians and the SVG command expects degrees.

The other thing you might note is that the text at every compass point is “N".  That's because I'm just using text[0] in the SVG command.  What I need to do is use the text corresponding to which iteration of the label I'm currently writing.  That's the reason I've been passing in the iteration parameter:
function rtext(svg, center, radius, startAngle, repeats, angle, iteration, op) {
    let x = center[0];
    let y = center[1]-radius;
    svg.append('text')
	.attr('x', x)
	.attr('y', y)
	.style('font-family', op.font)
	.style('fill', op.color)
	.style('font-size', op.size)
	.style('font-style', op.style)
	.style('font-weight', op.weight)
	.style('text-anchor', 'middle')
	.attr('transform', 'rotate('+rad2degrees(angle-Math.PI/2)+','+center[0]+','+center[1]+')')
	.text(op.texts[iteration]);
};
And with that in place we can stress test the code:

I haven't fussed too much with matching the fonts, etc., but for the most part I'm able to recreate the labels.  One obvious problem is that the line isn't blanked out behind the ordinal labels -- I'll look at that and some other challenges next time.  (And see “Suggestions to Explore" below.)

A reminder that if you want to follow along from home, you need to download the Part 8 branch from the Procedural Map Compasses repository on Github and get the test web page up and open the console.  Part 1 has instructions, but the short version (on Windows) is to double-click mongoose-free-6.9.exe to start up a web server in the Part 8 directory, and then select the test.html link.  Obviously you can browse the code and other files, and make changes of your own.  You can also try out this code on the web at https://dragonsabound-part8.netlify.app/test.html although you'll only be able to run the code, not modify it. 


Suggestions to Explore
  • How would you address the problem of blanking out behind the ordinal labels?  One possibility is to have an radial rectangle element (“RRECT"?) and use it to draw white rectangles where the labels will be.  Can you use RRECT to draw the black and white ring in this compass?
    These rectangles aren't really rectangles -- they're arcs of a line instead.  How will you draw that?

  • In the example compass directly above, the cardinal labels are in the correct positions, but the text has *not* been rotated.  Implement a version of RTEXT that puts the text in the correct position but does not rotate the text.  For the rotated version of RTEXT, the “anchor" of the text was at the bottom center of the text.  Where should the “anchor" of the unrotated text be located?

  • In the stress test example above, I used the “Serif" and “Helvetica" fonts.  Both these are generally available in the browser.  What if you want to use a font that's not normally available?  How do you load a font into the browser and use it in SVG?  Can you write a CDL command that loads a font and makes it available?

No comments:

Post a Comment