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?

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?

Friday, December 10, 2021

Map Compasses (Part 7): Compass Points

Welcome back to my blog series on implementing procedurally-generated map compasses!  So far I've implemented the language and interpreter for circles, space, and radial lines, circles and triangles, as shown in this demo image below:

This time I'm going to tackle compass points.  These are the two-sided diamond-shaped elements that typically point out to the points of the compass:
Like the lines and triangles, the compass points are a radial element that repeats around the circle.  As in the example above, there are often multiple points, the largest pointing at the cardinal directions (N, S, E, W), a shorter set of points at the ordinal directions (e.g., NE), a shorter set still pointing at the secondary intercardinal directions (e.g., NNE) and so on.
Regardless of the number, these are all versions of the same basic shape set at different intervals around the compass.

Typically the compass points are shaded as in the two examples above -- split down the middle with the right side dark and the left side light.  This is probably a remnant of picturing the compass points as 3D with lighting coming from the same direction as on the map (i.e., typically from the NE) but has largely become stylized.  You do sometimes see compasses where the shaded and lit sides of the points switch to more accurately reflect the lighting, or where the lighting comes from a different direction, as in this example: 
But the more stylized version seems more common today, and that's what I'll implement (at least initially).

 Unlike the other radial elements, compass points always extend all the way to the center of the compass, so the overall length will be decided by that distance.  (Which I've called the radius.)  The distance from the tip of the point to the widest point of the diamond (which I'm calling the span) can be any value less than the radius, and the width is the width of the diamond at that point:
But the width can't be just any distance.  If the compass points are on top and visible, then we want the width to be so that the point just touches the adjacent point, as in the top points in this compass:
The width should never be greater than that, because that would result in asymmetry depending on the order in which the points are drawn.  Sometimes the width can be less, though, as in the points in this compass:
This could be drawn several ways, but in any case the width does not extend all the way to touch the neighboring points, so that there's a “gap" through which the other points show.

I don't want to have to calculate the proper width to touch the adjacent compass point, so I'll let the interpreter do all the hard work.  I'll specify the width of compass point as a percentage from 0 to 1, where 1 is the proper width to touch the adjacent compass point.  Using a value less than 1 will create a gap.  But to give myself a little flexibility in case I do want to specify the width, I'll treat any width value greater than 1 as an absolute width.

This seems like a handy approach, so I'll do the same with the span parameter, so that it can be expressed as either a percentage of the radius or a specific value.

Now let me define the point command.  In addition to the normal radial element parameters, I need to specify the span, the width, the line width and color of the outline, and the light and dark fill colors:
RPOINT(start, repeats, span, width, lineWidth, lineColor, lightFill, darkFill)
and here's the corresponding parse rule in Nearley:
# A radial compass point element
# RPOINT(start, repeats, span, width, lineWidth, lineColor, lightFill, darkFill)
rpointElement -> "RPOINT"i WS "(" WS decimal WS "," WS decimal WS "," WS decimal WS "," 
                                  WS decimal WS "," WS decimal WS "," WS dqstring WS "," 
                                  WS dqstring WS "," WS dqstring WS")" WS
                 {% data => ({op: "RPOINT", start: data[4], repeats: data[8], span: data[12],
                              width: data[16], lwidth: data[20], lcolor: data[24], 
                              lightFill: data[28], darkFill: data[28]}) %}
and the corresponding if-else clause in the interpreter:
	} else if (op.op == 'RPOINT') {
// Draw radial compass points Draw.repeat(svg, center, radius, op.start, op.repeats, op, Draw.rpoint); if (debug) console.log('Draw radial compass points.'); }
Now I have to write the code to actually draw the compass point.

As I did with the radial triangles, in order to simplify my calculations, I'll draw all the compass points aligned straight up and down, and then rotate them to their final positions.  The first step is to calculate the start and end of the diamond and the midpoint:
function rpoint(svg, center, radius, startAngle, repeats, angle, i, op) {
    let start = [center[0], center[1]-radius];
    let end = center;
    // Midpoint is the point on the center line where the
    // diamond will have its maximum width
    let midpoint;
    if (op.span <= 1) {
	// Treat span as a % of radius
	op.span = op.span*radius;
    };
    midpoint = [center[0], center[1]-radius+op.span];
Here you can see how I treat span as a percentage if it is <= 1.

Figuring out the width is a little more difficult, and requires some trigonometry.  I'm not very skilled at this sort of problem, but I have an approach that works for me -- I send it to my kids, who both have math degrees.  A few minutes later I get this back:
Here Φ (phi) is the angle between two adjacent compass points.  This isn't the “angle" that gets passed into RPOINT -- that's the orientation of the compass point being drawn.  Phi is calculated from the number of repeats that are being drawn -- if there are 4 repeats, then the angle between two adjacent compass points is 90 degrees (or PI/2 radians).  (This is why I've been passing in repeats even though I didn't need it in the previous radial elements.)
function rpoint(svg, center, radius, startAngle, repeats, angle, i, op) {
    let start = [center[0], center[1]-radius];
    let end = center;
    // Midpoint is the point on the center line where the
    // diamond will have its maximum width
    let midpoint;
    if (op.span <= 1) {
	// Treat span as a % of radius
	op.span = op.span*radius;
    };
    midpoint = [center[0], center[1]-radius+op.span];
    // The maximum width is one that just touches the adjacent point.
    // Phi is the angle between adjacent points.
    const phi = (2*Math.PI)/repeats;
    const maxWidth = Math.tan(phi/2)*(radius-op.span)*2;
Now I can calculate the two side points of the diamond using the midpoint and the maxWidth.  For testing purposes I can temporarily borrow some code from RTRI to rotate the points as necessary and draw the compass point:
function rpoint(svg, center, radius, startAngle, repeats, angle, i, op) {
    let start = [center[0], center[1]-radius];
    let end = center;
    // Midpoint is the point on the center line where the
    // diamond will have its maximum width
    let midpoint;
    if (op.span <= 1) {
	// Treat span as a % of radius
	op.span = op.span*radius;
    };
    midpoint = [center[0], center[1]-radius+op.span];
    // The maximum width is one that just touches the adjacent point.
    // Phi is the angle between adjacent points.
    const phi = (2*Math.PI)/repeats;
    const maxWidth = Math.tan(phi/2)*(radius-op.span)*2;
    // Calculate the two side points
    let side1 = [center[0]-maxWidth/2, center[1]-radius+op.span];
    let side2 = [center[0]+maxWidth/2, center[1]-radius+op.span];
    // Rotate the diamond points.
    start = rotate(center, start, angle);
    end = rotate(center, end, angle);
    side1 = rotate(center, side1, angle);
    side2 = rotate(center, side2, angle);
    // Draw the outline
    let p = [start, side1, end, side2];
    svg.append('path')
	.style('stroke-width', op.lwidth)
	.style('stroke', op.lcolor)
	.style('fill', 'none')
	.attr('d', lineFunc(p));
};
Which produces this:
Looks like it worked!

There are a couple of things left to do.  First of all, I need to calculate the actual width rather than always use the maxWidth, and use this width to calculate the side points of the diamond:
    // Calculate the actual width based on width
    if (op.width <= 1) {
	op.width = op.width*maxWidth;
    };
    // Calculate the two side points
    let side1 = [center[0]-op.width/2, center[1]-radius+span];
    let side2 = [center[0]+op.width/2, center[1]-radius+span];
As with span, we treat the width parameter as a percentage of the maxWidth if it is less than 1; otherwise we just use it as an absolute value.

Finally, the dark and light sides of the compass also need to get filled.  To do this I'll make two new polygons consisting of one side of the diamond and then a line to connect the start and end of the diamond.  I'll fill these with the provided colors and then draw the outline last so that it's on top.
    // Create and fill the left (light) side area
    const left = [start, side1, end];
    svg.append('path')
	.style('stroke-width', 0)
	.style('stroke', 'none')
	.style('fill', lightFill)
	.attr('d', lineFunc(left));
    // Create and fill the right (dark) side area
    const right = [start, side2, end];
    svg.append('path')
	.style('stroke-width', 0)
	.style('stroke', 'none')
	.style('fill', darkFill)
	.attr('d', lineFunc(right));
    // Draw the outline
    let p = [start, side1, end, side2];
    svg.append('path')
	.style('stroke-width', lwidth)
	.style('stroke', lcolor)
	.style('fill', 'none')
	.attr('d', lineFunc(p));
And that's basically it:
And now I can add compass points to my test compass recreation:
We're getting there!  Next time I'll tackle labels and then this example compass at least will be complete.

A reminder that if you want to follow along from home, you need to download the Part 7 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 7 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-part7.netlify.app/test.html although you'll only be able to run the code, not modify it. 

Let me know in a comment or an email if you're making use of the code at all!

Suggestions to Explore

  • I've only implemented the standard “dark on the right" color scheme for compass points.  You can easily implement “dark on left" by swapping the fill colors in the RPOINT command.  Shading based on the light direction is more challenging.  Extend the code to add “shaded based on the lighting direction" as discussed above.  Add a parameter to the RPOINT command to indicate whether to shade “fixed" or a number that represents the angle of lighting in degrees.  How do you handle a parameter where the value can be a string or a number?

  • In the example compass, the ordinal compass points are smaller and sit “between" the cardinal compass points.  If the widest point of the ordinal compass points doesn't land exactly on the cardinal compass points, the compass looks wrong:

    I made the example work by trial-and-error to get the correct values for the span and width of the ordinal compass points.  That won't work when I'm using procedural generation.  How would you change the code to avoid this trial and error?

  • Several of the example compasses in the repository shade the points using lines, as in this example:

    How would you implement this?