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 and radial circles. Let's see what other capabilities we'll need to draw some compasses:
I can already do a surprising amount of this compass. There are only three things left to do: the triangles that mark the 45 degree angles, the two-toned diamonds that form the arms of the compass, and the labels. I'll start with the triangles.Radial triangles are a like radial circles with one important distinction: they're not symmetrical. When I drew the radial circles, the angle where I drew them was unimportant, since circles are symmetrical and look the same no matter how they are rotated. That's not going to be true for triangles. I'll have to rotate them to match the angle at which they are drawn.
Initially this seems a little daunting. I can figure out the math to draw a triangle pointing North. But figuring out how to draw one pointing NE (or some other random angle) is going to take some thought. Fortunately for me, there's a simpler workaround: draw the triangle pointing North and then rotate it around the center of the compass to the desired angle.
I'll start by defining a command for radial triangles:
RTRI(start, repeats, height, width, lineWidth, lineColor, fillColor)
This is similar to RCIRCLE, although here I need both a height and a width. (All the radial element commands will start with R.) And here's the Nearley rule to parse that:
# A radial triangle element # RTRI(start, repeats, height, width, lineWidth, lineColor, fillColor) rtriElement -> "RTRI"i WS "(" WS decimal WS "," WS decimal WS "," WS decimal WS "," WS decimal WS "," WS decimal WS "," WS dqstring WS "," WS dqstring WS ")" WS {% data => ({op: "RTRI", start: data[4], repeats: data[8], height: data[12], width: data[16], lwidth: data[20], color: data[24], fill: data[28]}) %}
Now I have to add an if-else clause to the interpreter to pick up this operator and call a handler function:
} else if (op.op == 'RTRI') {
// Draw radial triangles Draw.repeat(svg, center, radius, op.start, op.repeats, op, Draw.rtri); if (debug) console.log('Draw radial triangles.'); }
And so the signature of the rtri function is:
function rtri(svg, center, radius, start, repeats, angle, i, op)
Which is the same signature as all the radial element functions. Just a reminder of these parameters: Center is the center of the compass. Radius is the distance from the center to the base of the triangle. Start is the starting angle for the repeats. Repeats is the number of repeats around the compass. Angle is the angle of this repeat. I is which iteration of the repeats this call is. And op is the original RTRI command.
In the radial line element, height was measured down towards the center of the compass. I'll keep a consistent interpretation of height here, but I expect most triangles will point away from the center, meaning they'll have a negative height. So this means that the end point is just the triangle height closer to the center.
Here's a diagram that will hopefully clarify (remember I'm drawing all the triangles as if they point North):
Both start and end are directly above the center point. Here are the calculations for that:
function (svg, center, radius, start, repeats, angle, i, op) { const start=[center[0], center[1]-radius]; if(op.height==0) op.height = radius; const end=[start[0], center[1]-radius+op.height]; };
However, this isn't quite what I want. The start point is in the center of the base of the triangle, so I don't actually need that. What I need are the points to the left and right, like this:
Updated:
function (svg, center, radius, start, repeats, angle, i, op) { const left=[center[0]-op.width/2, center[1]-radius]; const right=[center[0]+op.width/2, center[1]-radius]; if(op.height==0) op.height = radius; const end=[start[0], center[1]-radius+op.height]; };I'm going to pause here and use the line drawing function to draw this triangle and make sure I've got everything right.
function (svg, center, radius, start, repeats, angle, i, op) { const left=[center[0]-op.width/2, center[1]-radius]; const right=[center[0]+op.width/2, center[1]-radius]; if(op.height==0) op.height = radius; const end=[start[0], center[1]-radius+op.height]; line(svg, left, right, op.lwidth, op.color); line(svg, right, end, op.lwidth, op.color); line(svg, end, left, op.lwidth, op.color); };Looks good! Now let me tackle rotating the triangle to the proper location.
To do this, I need to rotate all the points in the triangle around the center of the compass by angle. Right now I have the points as left, right and end, which is not very convenient. I'll modify rtri to create an array of the triangle points:
function rtri(svg, center, radius, start, repeats, angle, i, op) { if (op.height == 0) op.height = radius; let triangle = [[center[0]-op.width/2, center[1]-radius], [center[0]+op.width/2, center[1]-radius], [center[0], center[1]-radius+op.height]];
line(svg, triangle[0], triangle[1], op.lwidth, op.color); line(svg, triangle[1], triangle[2], op.lwidth, op.color); line(svg, triangle[2], triangle[0], op.lwidth, op.color); };Now I need a function to rotate a point through an angle . In Javascript, that looks like this:
// // Rotate p2 around p1 by angle (in radians) // function rotate(p1, p2, angle) { let cos=Math.cos(angle),sin=Math.sin(angle), nx=(cos*(p2[0]- p1[0]))-(sin*(p2[1]- p1[1]))+ p1[0], ny=(cos*(p2[1]- p1[1]))+(sin*(p2[0]- p1[0]))+ p1[1]; return[nx, ny]; }I need to apply this function to all the points in the triangle array. There are various ways to do this in Javascript -- a for loop is an obvious choice -- but Javascript has a feature specifically for this sort of operation, called map. Map takes an array and a function and returns a new array that results from applying the function to every element in the list. Now I can't just map the rotate function directly, because the arguments won't be right. So I need an arrow function to fix that up.function rtri(svg, center, radius, start, repeats, angle, i, op) { if (op.height == 0) op.height = radius; let triangle = [[center[0]-op.width/2, center[1]-radius], [center[0]+op.width/2, center[1]-radius], [center[0], center[1]-radius+op.height]]; triangle= triangle.map(pt => rotate(center, pt, angle)); line(svg, triangle[0], triangle[1], op.lwidth, op.color); line(svg, triangle[1], triangle[2], op.lwidth, op.color); line(svg, triangle[2], triangle[0], op.lwidth, op.color); };For every pt in triangle, the map function calls “rotate(center, pt, angle)" and collects the results in a new list (which gets put back into triangle).And now every triangle gets drawn at the proper spot. All that remains is to fill in the triangle. To do that, I have to draw the triangle differently. Right now I'm drawing it as three separate lines, which means there's no “inside" to fill. I need to draw it as connected lines.There are a couple of ways to do this in SVG, but I typically use an SVG path. SVG paths are a bit of a nightmare if I'm being honest, but they're very flexible and can be used in ways that simpler methods don't allow. And here again D3 will come to our rescue with several powerful methods for constructing SVG paths. Principally I use d3.line, which is a function that creates a function that turns a polygon into the commands expected in the SVG path command. (Whew!)In practice, it's not too difficult to understand:const lineFunc= d3.line(). x(pt=> pt[0]). y(pt=> pt[1]). curve(d3.curveLinearClosed);This creates a function called “lineFunc" that takes a list of points. In x and y I define how you get the x and y values out of a point. In this case, points are arrays like [x, y], so the x value is the first element of the array and the y value is the second element of the array. Curve is used to specify how points are connected. In this case, I want to use straight lines between the points and I want to draw a closed polygon that connects the last point back to the first point. D3 itself provides a curve function that does that, called d3.curveLinearClosed. D3 provides a bunch of different curves you can use to connect points -- you can explore some here .
With lineFunc defined, the code to draw the triangle as a path instead of three separate lines is simple:
function rtri(svg, center, radius, start, repeats, angle, i, op) { if (op.height == 0) op.height = radius; let triangle = [[center[0]-op.width/2, center[1]-radius], [center[0]+op.width/2, center[1]-radius], [center[0], center[1]-radius+op.height]]; triangle = triangle.map(pt => rotate(center, pt, angle)); svg.append('path') .style('stroke-width', op.lwidth) .style('stroke', op.color) .style('fill', op.fill) .attr('d', lineFunc(triangle)); };The only non-obvious part is passing the output of lineFunc into the “d" attribute. (I have no clue why it is called that.)I haven't bothered to get this exactly right, but we're a long ways towards generating a pretty complex and interesting compass. And SVG path will come in handy next time when I tackle the compass points.Just for fun, here's the CDL to generate the partial compass above:RLINE(0, 72, 8, 0.5, "rgb(108,65,38)")SPACE(2)RLINE(0, 360, 4, 0.25, "rgb(108,65,38)")SPACE(3)RTRI(0, 8, -8, 6, 0.5, "rgb(108,65,38)", "white")CIRCLE(3, "rgb(108,65,38)", "none")SPACE(8)CIRCLE(0.5, "rgb(108,65,38)", "none")SPACE(8)CIRCLE(2, "rgb(108,65,38)", "none")SPACE(4)RCIRCLE(0, 24, 1, 0, "none", "rgb(108,65,38)")SPACE(4)CIRCLE(2, "rgb(108,65,38)", "none")SPACE(2)CIRCLE(0.5, "rgb(108,65,38)", "none")And this shows how to input an arbitrary RGB color in CDL.A reminder that if you want to follow along from home, you need to download the Part 6 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 6 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-part6.netlify.app/test.html although you'll only be able to run the code, not modify it.Suggestions to Explore
- The RTRI code is the basis for any sort of polygon. Try implementing diamonds, or for the adventurous, star shapes.
- Try re-implementing RLINE using the method in RTRI of drawing the line at noon and then rotating it to the proper position. If you implemented RCROSS in Part 5, try re-implementing it using the draw and rotate approach. Is that easier/better?
Have you considered what will happen if/when you need a triangle wider than 3 or 4 degrees? That linear baseline is going to look pretty ugly. It might be worth taking the trouble now to use an arc for the base instead of a straight line.
ReplyDeleteI've *thought* about it. Have I done anything about it? No :-) So far I haven't used a triangle that wide but if I do you're right it will probably need an arc.
ReplyDeleteThe name for these things is "compass rose". BTW, a book you might be interested in is "U.S. Chart No 1: Symbols, Abbreviations and Terms used on Paper and Electronic Navigational Charts"
ReplyDeleteThanks for the pointers! I'll look for the book.
ReplyDelete