Friday, January 7, 2022

Map Compasses (Part 9): Vertical Text and Radial Arcs

Happy 2022!  Hopefully this year will be better than the last two -- that shouldn't be difficult.  

Welcome back to my blog series on implementing procedurally-generated map compasses!  So far I've implemented the language and interpreter for all the capabilities needed to draw compasses like this example:

In the last part I implemented radial labels as in the example above, but I noted that many compasses do not rotate the labels, especially for the compass points, as in this example:
So let me implement this option.  (This was one of my “Suggestions to Explore" in the last part.)  To start with, I'll add an option to the RTEXT() command to control the orientation of the labels, either “radial" or “vertical."
# Radial text
# RTEXT(start, repeats, font, size, color, style, orientation, texts)
rtextElement -> "RTEXT"i WS "(" WS decimal WS "," WS decimal WS "," WS dqstring WS ","
	     		        WS decimal WS "," WS dqstring WS "," WS dqstring WS "," 
				WS dqstring WS "," WS dqstring WS "," WS dqList WS ")" 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],
			      orientation: data[32], texts: data[36]}) %}
Now I'll fix the code to draw the label at the correct spot and with the vertical orientation if that option is chosen.  Since I don't have to rotate the text, I can do this by simply rotating the spot at which I'm going to draw the label just as I did for the other radial elements.
function rtext(svg, center, radius, startAngle, repeats, angle, iteration, op) {
    let x = center[0];
    let y = center[1]-radius;
    // If orientation == vertical, then rotate [x, y] around the center by
    // angle to find the spot for the label.
    if (op.orientation == 'vertical') {
	[x, y] = rotate(center, [x, y], angle-Math.PI/2);
    };
    // Now draw the label
    let label = 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')
	.text(op.texts[iteration]);
    // If orientation == radial, then we've drawn the label at 12 o'clock
    // and need to use SVG transform to rotate it around to the correct
    // spot.
    if (op.orientation != 'vertical') {
	label.attr('transform', 'rotate('+rad2degrees(angle-Math.PI/2)+','+center[0]+','+center[1]+')');
    };
};
Note that I don't have to set all the attributes and styles of an SVG element when it is created.  I can add or change these later, so I take advantage of that to add the rotation to the radial labels after drawing them as vertical labels.

Let me test this on the N/S/E/W labels from the complicated compass up above:
The labels are now vertical but the placement is off.  Why?  If you look at the code above you'll see that I'm setting the text-anchor of the label to “middle".  This is the middle of the bottom of the label, so SVG is putting that spot at the tip of each of the compass points.
That works for north but not for the other positions.  Instead of anchoring to the bottom of the text, in this case I should anchor to the center of the text [1].  This can be accomplished with something called the “dominant-baseline" style.  Setting this to “central" does [2] what we want:
although now we have the problem that the labels are impaled on the compass points.

[1] Not really.  See below.
[2] This setting doesn't really do exactly what we want, for reasons having to do with text complications like ascenders and descenders and the possibility of subscript accent marks and so on.  But this gets pretty close in most cases.

I can address the impalement by drawing the labels further out:

This works fairly well but you can see that the placement of W and E are different.  That's because W is a wider character than E.  I can make one of the labels longer to make the problem more apparent:
I think for most compasses a “close enough" approach is fine because the labels are usually the same number of characters, but see the Suggestions below for a better approach.  (And if you really want to be finicky, you can place each label using a separate RTEXT command to fully control where they are displayed.)

And here's a recreation of the sample compass from above with its vertical labels:
Another problem with the recreation of the fancy compass is that the line behind the ordinal labels (NE/SE/SW/NW) is not blocked out where the labels are (compare left to right):
I suggested last time that this could be handled by creating a radial rectangle element and then putting white rectangles between the label and the line.  If I implement this to support any color, then alternating white and black rectangles would enable the radial scale in this example:
Although those elements aren't really rectangles, they're arcs of a circle, so I'll call this command RARC.  Here's the CDL command:
# Radial arc
# RARC(start, repeats, subtend, width, color)
rarcElement -> "RARC"i WS "(" WS decimal WS "," WS decimal WS "," WS decimal WS ","
	       	              WS decimal WS "," WS dqstring WS ")" WS
                 {% data => ({op: "RARC", start: data[4], repeats: data[8], subtend: data[12],
                              width: data[16], color: data[20]}) %}
“Subtend" is the angle between the two ends of the arc, e.g., setting this to 20 degrees will result in 18 arcs around the circle.  (Although I'll use radians not degrees.)

Drawing an arc is a bit of a challenge.  SVG has a path command for drawing an elliptical arc; it's notoriously difficult.  As is often the case, D3 provides a much simpler way to draw a circular arc.  However, I'm going to choose not to use an SVG command and instead draw the arc directly.  I have two motivations to do it this way.  First, it's a more interesting coding problem.  Second, while SVG will draw a perfect arc, when I use this code in DA I'll want to be able to draw a (subtly) imperfect arc to make it look hand-drawn.  To do that, I need to do the drawing myself.

So, how do you draw an arc?  It starts with drawing a circle, or rather a polygon that is like a circle:
// Make a circular polygon
function makeCircle(center, radius, num) {
    const result = [];
    const step = (2*Math.PI)/num;
    for(let t=0;t<(2*Math.PI);t += step) {
	result.push([radius*Math.cos(t)+center[0],radius*Math.sin(t)+center[1]]);
    };
    // Close off the circle
    result.push(result[0]);
    return result;
};
What this does is step around a circle num times, adding the point at each step to a polygon.  So if num was 5, the result is a five-sided polygon (a pentagon) inscribed in the circle. 
(The returned polygon has six points in it, because the first point is repeated at the end to “close" the polygon.)  As you increase the number of sides, the polygon gets closer and closer to a circle.
To turn makeCircle into makeArc, I just need to only step over the angles of the arc rather than all the way around the circle. 
// Make a circular arc
function makeCircularArc(center, radius, start, end, num) {
    const [x, y] = center;
    const result = [];
    const step = (end-start)/num;
    for(let t=start;t<end;t += step) {
	result.push([radius*Math.cos(t)+x,radius*Math.sin(t)+y]);
    };
    result.push([radius*Math.cos(end)+x,radius*Math.sin(end)+y]);
    return result;
};
The rarc function starts by creating the inside and the outside edges of the arc:
function rarc(svg, center, radius, startAngle, repeats, angle, iteration, op) {
    const arcStart = angle-0.5*op.subtend;
    const arcEnd = angle+0.5*op.subtend;
    const outsideEdge = makeCircularArc(center, radius, arcStart, arcEnd, 20);
    const insideEdge = makeCircularArc(center, radius-op.width, arcStart, arcEnd, 20);
};
I say inside and outside, but I'm following the same convention for the size of radial elements I have before.  One edge of the arc (called the outsideEdge above) is “radius" away from the center point of the compass.  The other edge is closer to the center point if op.width is positive, and further away if negative.

Note that I'm centering the arc on “angle."  That seems more intuitive to me than starting at angle and going clockwise or counter-clockwise.

To connect the two edges into a polygon, I need to reverse the order of points in one of them, and then concatenate the two edges together.  Then I can draw it like any other path, but in this case there's no edge, it's just a filled area of color.
function rarc(svg, center, radius, startAngle, repeats, angle, iteration, op) {
    const arcStart = angle-0.5*op.subtend;
    const arcEnd = angle+0.5*op.subtend;
    const outsideEdge = makeCircularArc(center, radius, arcStart, arcEnd, 20);
    const insideEdge = makeCircularArc(center, radius-op.width, arcStart, arcEnd, 20);
    const polygon = outsideEdge.concat(insideEdge.reverse());
    svg.append('path')
	.style('stroke-width', 0)
	.style('stroke', 'none')
	.style('fill', op.color)
	.attr('d', lineFunc(polygon));
};
And here is a result:
This is using 20 points on the edges; that seems to be good enough to give a smooth curve at these sizes.  By setting the color to white and placing it immediately behind the ordinal direction labels in this compass, I can now mask out the line:

A reminder that if you want to follow along from home, you need to download the Part 9 branch from the Procedural Map Compasses repository on Github and get the test web page up.  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 9 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-part9.netlify.app/test.html although you'll only be able to run the code, not modify it. 

Suggestions to Explore
  • I've suggested that RARC can be used to draw the alternating black and white scale as used in this compass:

    Write the CDL for this compass and generate it.  How did you handle the lines around the white arcs?

  • For map borders, I sometimes generate a scale with three colors:
    Create a compass with a three-colored scale with all the colors outlined.  How did you handle the lines between the arcs?

  • This compass has triangles at the ordinal points on the outer scale that have black and white halves like compass points:
    Implement this as an option in RTRI.  Ignoring the hatched shading, can you now recreate the entire compass?

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.