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?
Cool! It wasn't until I started to make a compass rose inspired by your blog post that it struck me just how amazingly varied your generator is. My quick&dirty playground is https://observablehq.com/@redblobgames/compass-rose . I *think* writing the svg with interpolation literals made me happier than writing the d3 commands but I'm only doing simple things on that page and don't know how well that will scale to the more involved things you're doing.
ReplyDeleteNow I know how you do all this so well. Alas, my kid is six and currently excavating a crashed freighter in No Man's Sky, so I have to work out all my maths myself...
ReplyDeleteI'm willing to rent out my kids for a reasonable fee :-)
Delete