Welcome back to my blog series on implementing procedurally-generated map compasses! In the previous post, I write the code for the language commands CIRCLE and SPACE. In this posting, I'm going to work on commands for radial elements.
Radial elements are the parts of the compass that repeat at regular intervals in a circle around the center point of the compass. For example, consider this very simple compass:
You could view this compass as having a line repeated every 90 degrees, plus a label “N" repeated every 360 degrees. A more complex example:Here's a compass with a lot of radial elements. Labels of various sorts, but also short lines (and somewhat longer lines) that provide the degrees scale, some triangles to mark the diagonals on that scale, a set of smaller compass points and a set of larger compass points, and even some little dots for decoration in an inner ring. (And everything else in the compass is some version of CIRCLE.)RLINE(start, repeats, length, lineWidth, color)
This command will draw a radial line of the given length in the given color. The length will start at the current radius and extend in towards the center of the compass. But I'll also support a negative length, which will let me draw outward as well. It will be handy to have a shorthand syntax that means “draw all the way to the center of the compass" so that I don't have to keep track of that distance when generating CDL. Zero is conveniently unused in this scheme, so I'll use a zero length to mean “all the way to the center."
The first step in implementing this is adding it to the parser. The rule for this command is long but straightforward:
# A radial line element rlineElement -> "RLINE"i WS "(" WS decimal WS "," WS decimal WS "," WS decimal WS "," WS decimal WS "," WS dqstring WS ")" {% data => ({op: "RLINE", start: data[4], repeats: data[8], length: data[12], width: data[16], color: data[20]}) %}
# A list of all the legal CDL elements Element -> spaceElement | circleElement | rlineElement
A description that includes RLINE will now be parsed. (Remember that when you modify the Nearley grammar file, you need to recompile it using the command line at the top of the file.)
The next step is to implement RLINE in the interpreter (in compass.js). First, we need to add RLINE to our if-else in the interpreter that dispatches based on the op code:
} else if (op.op == 'RLINE') {
// Draw radial lines Draw.rline(svg, center, radius, op.start, op.repeats, op.length, op.width, op.color); if (debug) console.log('Draw radial lines.'); } else {
I've filled in a call here to “Draw.rline" that will do the actual work of drawing the radial lines. “Op" here is the Javascript structure built in the Nealey rule above, and op.start, etc., are the parameters. Now I'll need to jump over to draw.js and write that function.
The heart of the function is a loop that draws the proper number of repeats around the circle.
function rline(svg, center, radius, start, repeats, len, width, color) { for(let angle = start; angle < start+Math.PI*2; angle += (Math.PI*2)/repeats) { }; };
angle is where I am currently drawing. It starts at the start angle and loops all the way around and back to the same spot. I'm working in radians here, so a full circle is 2PI. Every step advances 2PI/repeats, which means I'll draw repeat number of elements as I go around the circle. From the angle and the radius I need to calculate the coordinates of the actual point for the element. Recalling my old friend Sohcahtoa from high school geometry, and remembering to measure from the center, I add those calculations:
function rline(svg, center, radius, start, repeats, len, width, color) { for(let angle = start; angle < start+Math.PI*2; angle += (Math.PI*2)/repeats) { const sx = center[0] + radius * Math.cos(angle); const sy = center[1] + radius * Math.sin(angle); }; };
Actually I lied just a little bit right there. In SVG (and in many computer graphics systems), the Y axis is reversed from your familiar days in high school geometry. The origin of a window is typically at the upper left, and Y goes up as you move down from the origin. In practical terms, this means I have to reverse the sign on the calculation of sy:
function rline(svg, center, radius, start, repeats, len, width, color) { for(let angle = start; angle < start+Math.PI*2; angle += (Math.PI*2)/repeats) { const sx = center[0] + radius * Math.cos(angle); const sy = center[1] - radius * Math.sin(angle); }; };
(And yes, I messed this up when I first wrote the code. I always mess this up.)
With that corrected, I can do a similar calculation to determine the end point of the line, but instead of using radius, I'll subtract the length. So a positive length will result in a point closer to the center, as desired. And I need to remember the special treatment when length == 0, which indicates a line that goes to the center of the compass.
function rline(svg, center, radius, start, repeats, len, width, color) { for(let angle = start; angle < start+Math.PI*2; angle += (Math.PI*2)/repeats) { const sx = center[0] + radius * Math.cos(angle); const sy = center[1] - radius * Math.sin(angle); // Length == 0 indicates a line that goes to the center of the compass if (len == 0) len = radius; const ex = center[0] + (radius-len) * Math.cosine(angle); const ey = center[1] - (radius-len) * Math.sine(angle); }; };
Now that I have the start and the end of the line I can call a line drawing routine. This is very straightforward:
function line(svg, start, end, width, color) { svg.append('line') .style('stroke-width', width) .style('stroke', color) .attr('x1', start[0]) .attr('y1', start[1]) .attr('x2', end[0]) .attr('y2', end[1]); };
This appends an SVG <line> element to the SVG. Let me feed a CDL that uses a RLINE element into the test routine:
CIRCLE(2, “black", “none") RLINE(0, 8, 4, 2, “black")
CIRCLE(2, “black", “none") RLINE(0, 8, 0, 2, “black")
Before I get too happy, let's check a couple of other things. How about a single line:
Well, partial success. It's a single line as expected, but pointing at 3 o'clock rather than 12 o'clock. In retrospect, this makes sense. The starting angle is 0, and a zero angle is right on the X axis. I should have started at 90 degrees instead. On the other hand, I'm always going to want to think of noon as the zero angle on a compass, so I'll shift the start angle by 90 degrees before I start drawing:CIRCLE(2, “black", “none") RLINE(0, 1, 0, 2, “black")
function rline(svg, center, radius, start, repeats, len, width, color) { for(let angle = start+(Math.PI/2); angle < start+Math.PI*2; angle += (Math.PI*2)/repeats) { const sx = center[0] + radius * Math.cos(angle); const sy = center[1] - radius * Math.sin(angle); // Length == 0 indicates a line that goes to the center of the compass if (len == 0) len = radius; const ex = center[0] + (radius-len) * Math.cos(angle); const ey = center[1] - (radius-len) * Math.sin(angle); line(svg, [sx, sy], [ex, ey], width, color); }; };
Now let me quickly implement another radial element, one that draws circles like this:
I won't go through adding this to the parser and the interpreter, as it is much the same as RLINE. But here is the function that implements RCIRCLE:
function rcircle(svg, center, radius, start, repeats, len, width, color, fill) { for(let angle = start+(Math.PI/2); angle < start+Math.PI*2+(Math.PI/2); angle += (Math.PI*2)/repeats) { const sx = center[0] + radius * Math.cos(angle); const sy = center[1] - radius * Math.sin(angle); // Length == 0 indicates a line that goes to the center of the compass if (len == 0) len = radius; const ex = center[0] + (radius-len) * Math.cos(angle); const ey = center[1] - (radius-len) * Math.sin(angle); circle(svg, [sx, sy], len, width, color, fill); }; };
In this case “len" is the size of the circle to draw so I don't actually need the end point. I left it in to illustrate how similar RLINE and RCIRCLE are -- they're identical except for the actual call to the drawing function. All of the radial elements are going to share the same repeats loop and this suggests that can be abstracted out and not rewritten in every element.
To do that, I'll write a function that implements the repeats loop and takes the drawing function as a parameter. That looks like this:
function repeat(svg, center, radius, start, repeats, op, fn) { let angle = start+(Math.PI/2); for(let i=0;i<repeats;i++) { fn(svg, center, radius, start, repeats, angle, i, op); angle += (Math.PI*2)/repeats; }; };
The new repeat function loops all the radial placements and calls fn with the SVG, the start angle, the number of repeats, the angle, the iteration and the original operation object. Some of these parameters aren't needed for RLINE or RCIRCLE but (warning: foreshadowing!) they will be needed for future functions. In particular, passing in the operation object will give our drawing routines access to the parameters that were in the original CDL command.
Now we can replace the call to RCIRCLE with a call to repeat. This code in the interpreter:
} else if (op.op == 'RCIRCLE') {
// Draw radial circles Draw.rcircle(svg, center, radius, op.start, op.repeats, op.length, op.width, op.color, op.fill); if (debug) console.log('Draw radial circles.'); } else {
becomes:
} else if (op.op == 'RCIRCLE') {
// Draw radial circles Draw.repeat(svg, center, radius, op.start, op.repeats, op, Draw.rcircle); if (debug) console.log('Draw radial circles.'); } else {
Now I have to adjust the Draw.rcircle function slightly to handle the new parameters.
function rcircle(svg, center, radius, start, repeats, angle, i, op) { const sx = center[0] + radius * Math.cos(angle); const sy = center[1] - radius * Math.sin(angle); circle(svg, [sx, sy], op.length, op.width, op.color, op.fill); };And then a similar thing for RLINE:
} else if (op.op == 'RLINE') {
// Draw radial lines Draw.repeat(svg, center, radius, op.start, op.repeats, op, Draw.rline2); if (debug) console.log('Draw radial lines.');}I'll define the rline2 function to calculate the start and end points and call Draw.line:
function rline2(svg, center, radius, start, repeats, angle, i, op) { // Length == 0 indicates a line that goes to the center of the compass if (op.length == 0) op.length = radius; const sx = center[0] + radius * Math.cos(angle); const sy = center[1] - radius * Math.sin(angle); const ex = center[0] + (radius-op.length) * Math.cos(angle); const ey = center[1] - (radius-op.length) * Math.sin(angle); line(svg, [sx, sy], [ex, ey], op.width, op.color); };I've used the kind of awkward rline2 name here because I wanted to leave the original rline function in the code so that you could see it there.
That's where I'll stop for today. A reminder that if you want to follow along from home, you can download the Part 5 branch from the Procedural Map Compasses repository on Github. Part 1 has more detailed 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 5 directory, and then select the test.html link. Obviously you can browse the code and other files, and make changes of your own. Try writing some CDL, inserting it into the test function, and using the button to generate it. You can also try out this code on the web at https://dragonsabound-part5.netlify.app/test.html although you'll only be able to run the code, not modify it.
I've left the code set up to produce this:
But the CDL for all the examples is included in the code in comments just above the test function in compass.js.
Suggestions to Explore
- Implement RCROSS that draws a radial cross element. This is the same as RLINE but it adds a horizontal line across the existing line. How did you deal with getting the horizontal line at the correct angle? Extend RCROSS to an asterisk by adding the two lines at 45 degree angles.