Skip to content

Dead simple bullseye map

Get a pattern working once by keeping it simple and then you can reduce scripts to produce different outputs by changing only a few lines.

How to get it all sewn up. Public domain

Table of Contents

Target

How often do you have to do a key map, something like this?

Point of interest in context

There is an interactive version, also, showing added goodies of zoom, re-centering, re-scaling all based on the leaflet Javascript library.

Pattern work

Hand editing HTML is a PITA and that hasn't changed much in the 35+ years since I started doing it with the vi editor. The good news is that it has always been possible to tame the problem by isolating the static portion that doesn't change every time from the few, frequently changing portions.

What stays the same

All the usual scaffolding

<document_content>
<!DOCTYPE html>
<html>
<head>
  <title>Leaflet Template</title>
  <link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet-0.7.3/leaflet.css" />
  <script src="http://cdn.leafletjs.com/leaflet-0.7.3/leaflet.js">
  </script>
  <style>
    .flex-container {
        display: flex;
        align-items: flex-start; /* Align items at the start of the container */
    }
    #map {
        width: 500px;
        height: 580px;
        margin-right: 20px; /* Add some space between the map and the tables */
    }
    .tables-container {
        display: flex;
        flex-wrap: wrap; /* Allow tables to wrap if there's not enough space */
        gap: 10px; /* Space between tables */
    }
    table {
        border-collapse: collapse;
        width: 200px; /* Adjust based on your preference */
    }
    th, td {
        border: 1px solid black;
        padding: 8px;
        text-align: right;
    }
  </style>
</head>

…
    </script>
  </body>
</html>
</document_content>

What changes

A few lines like this

addConcentricCircles([addConcentricCircles([41.258611111, -95.9375], radii, colors);
], radii, colors);

How to isolate

The spot

The purpose of map illustration is to show a point of interest and its distance from others.

from = "Omaha"

Locating the POI is done with specifying latitude and longitude. For example, Omaha is

centerpoint = "41.258611111, -95.9375"

The bands

bands       = "50, 100, 200, 300, 400"
band_colors = "'Red', 'Green', 'Yellow', 'Blue', 'Purple'"

How to insert

String interpolation

We've all done this in word processing:

Dear SALUTATION:

in a boilerplate (form) document and then do a search and replace.

In programming, however, we define string variables

centerpoint = "41.258611111, 95.9375"
from = "Omaha"
bands = "50, 100, 200, 300, 400"
band_colors = "'#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF']"

that we can use as placeholder variables in other strings

snippet = " The coordinates of $from are $centerpoint"

from which we will get

The coordinates of Ohama are 41.258611111, 95.9375

Where to insert it

In a script, of course, in any language that does string interpolation. According to my buddy Claude these include

Groovy:

def name = "John"
def age = 25
println "My name is $name and I'm $age years old."

Perl:

my $name = "John";
my $age = 25;
print "My name is $name and I'm $age years old.";

PHP:

$name = "John";
$age = 25;
echo "My name is $name and I'm $age years old.";

Scala:

val name = "John"
val age = 25
println(s"My name is $name and I'm $age years old.")

Kotlin:

val name = "John"
val age = 25
println("My name is $name and I'm $age years old.")

Swift:

let name = "John"
let age = 25
print("My name is \(name) and I'm \(age) years old.")

C# (using string interpolation):

string name = "John";
int age = 25;
Console.WriteLine($"My name is {name} and I'm {age} years old.");

Ruby:

name = "John"
age = 25
puts "My name is #{name} and I'm #{age} years old."

JavaScript (using template literals):

const name = "John";
const age = 25;
console.log(`My name is ${name} and I'm ${age} years old.`);

Python (using f-strings):

name = "John"
age = 25
print(f"My name is {name} and I'm {age} years old.")

And my favorite

from * " is located at " * coords
"Omaha is located at 41° 15′ 31″ N, 95° 56′ 15″ W"

and bash is also good.

So, the script

centerpoint = "41.258611111, 95.9375"
from = "Omaha"
bands = "50, 100, 200, 300, 400"
band_colors = "'#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF']"
include("partials.jl")

where partials.jl is a separate file with the template that contains all of the constant stuff, with `"something = $centerpoint".

bullseye =
"""
<document_content>
<!DOCTYPE html>
<html>
<head>
  <title>Leaflet Template</title>
  <link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet-0.7.3/leaflet.css" />
  <script src="http://cdn.leafletjs.com/leaflet-0.7.3/leaflet.js">
  </script>
  <style>
    .flex-container {
        display: flex;
        align-items: flex-start; /* Align items at the start of the container */
    }
    #map {
        width: 500px;
        height: 580px;
        margin-right: 20px; /* Add some space between the map and the tables */
    }
    .tables-container {
        display: flex;
        flex-wrap: wrap; /* Allow tables to wrap if there's not enough space */
        gap: 10px; /* Space between tables */
    }
    table {
        border-collapse: collapse;
        width: 200px; /* Adjust based on your preference */
    }
    th, td {
        border: 1px solid black;
        padding: 8px;
        text-align: right;
    }
</style>
</head>

<body>
<div class="flex-container">
  <div id="map">
  </div>
  <div class="tables-container">
  </div>
</div>
<script>
// Creating map options
var mapOptions = {
   center: [$centerpoint],
   zoom: 5
};
var map = new L.map('map', mapOptions);

var layer = new L.TileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png');

map.addLayer(layer);

var marker = L.marker([$centerpoint]);

marker.addTo(map);

marker.bindPopup($from).openPopup();

function milesToMeters(miles) {
   return miles * 1609.34;
}
var colors = [$band_colors];
var radii = [$bands];
function addConcentricCircles(center, radii, colors) {
   radii.forEach(function(radius, index) {
      L.circle(center, milesToMeters(radius), {
         color: colors[index],
         fillColor: colors[index],
         fillOpacity: 0
      }).addTo(map);
   });
}

addConcentricCircles([$centerpoint], radii, colors);
// Adding a legend
var legend = L.control({position: 'bottomleft'}); // Change position to 'bottomleft'

legend.onAdd = function (map) {
   var div = L.DomUtil.create('div', 'info legend'),
       labels = ['<strong>Distances</strong>'],
       
       distances = [$bands];

   for (var i = 0; i < distances.length; i++) {
       div.innerHTML += 
           '<i style="background:' + colors[i] + '; width: 18px; height: 18px; float: left; margin-right: 8px; opacity: 0.7;"></i> ' +
           distances[i] + (distances[i + 1] ? '&ndash;' + distances[i + 1] + ' miles<br>' : '+ miles');
   }

   return div;
};

legend.addTo(map);
</script>
</body>
</html>
</document_content>
"""

Then, the rest of the script to generate the page and open it in your browser is just

open("omaha.html", "w") do file
           write(file, bullseye)
       end
file_path = "omaha.html"
run(`open $file_path`)

(This is for macOS; consult your bot for other platform.)

What if?

Ok, you want different colors.

Colors

pal = ("'Red', 'Green', 'Yellow', 'Blue', 'Purple'",
       "'#E74C3C', '#2ECC71', '#3498DB', '#F1C40F', '#9B59B6'",
       "'#FF4136', '#2ECC40', '#0074D9', '#FFDC00', '#B10DC9'",
       "'#D32F2F', '#388E3C', '#1976D2', '#FBC02D', '#7B1FA2'",
       "'#FF5733', '#C70039', '#900C3F', '#581845', '#FFC300'")


band_colors = pal[4]

`

Your coordinates don't coordinate

Yeah, we need decimal coordinates and if given degrees, minutes, seconds, no joy.

"""
    dms_to_decimal(coords::AbstractString) -> AbstractString

Convert latitude and longitude coordinates from degrees, minutes, seconds (DMS) format 
to decimal degrees (DD) format as a string.

# Arguments
- `coords`: A string representing the latitude and longitude coordinates in the format 
  "41° 15′ 31″ N, 95° 56′ 15″ W".

# Returns
- A string containing the latitude and longitude coordinates in decimal degrees format,
  separated by a comma, e.g., "41.258611111, -95.9375".

# Example
```julia
coords = "41° 15′ 31″ N, 95° 56′ 15″ W"
result = dms_to_decimal(coords)
println(result)  # Output: "41.258611111111111, -95.9375"
"""
function dms_to_decimal(coords::AbstractString)
# Split the input string into latitude and longitude parts
lat_dms, lon_dms = split(coords, ",")
    # Function to convert DMS to decimal degrees
    function to_decimal(dms::AbstractString)
        # Remove any extra whitespace
        dms = strip(dms)
        
        # Extract the degree, minute, and second values
        deg, min, sec, dir = match(r"(\d+).\s*(\d+)′\s*(\d+(?:\.\d+)?)″\s*([NSEW])", dms).captures
        
        # Convert the values to floats
        deg = parse(Float64, deg)
        min = parse(Float64, min)
        sec = parse(Float64, sec)
        
        # Calculate the decimal degrees
        decimal = deg + (min / 60) + (sec / 3600)
        
        # Adjust the sign based on the direction
        decimal *= (dir == "S" || dir == "W") ? -1 : 1
        
        return decimal
    end
    
    # Convert latitude and longitude to decimal degrees
    lat_decimal = to_decimal(lat_dms)
    lon_decimal = to_decimal(lon_dms)
    
    # Format the output string
    result = "$(lat_decimal), $(lon_decimal)"
    
    return result
end

coords = "41° 15′ 31″ N, 95° 56′ 15″ W"
result = dms_to_decimal(coords)
println(result)  # Output: "41.258611111111111, -95.9375"

Aside

If you've worked in other languages, the Julia is fairly easy to read, but what's up with the function signature?

function dms_to_decimal(coords::AbstractString)
`

Julia is somewhat of an oddball. The function could have been just ducktyped.

function dms_to_decimal(coords) 

But on the advice of Claude, I've strictly typed it.

function dms_to_decimal(coords::AbstractString)

And AbstractString? Here's what he says about that.

In duck-typed languages like Python or Ruby, the type of a variable is determined by its behavior rather than being explicitly declared. In other words, if an object quacks like a duck and walks like a duck, it is considered a duck, regardless of its actual type.

In contrast, Julia is a statically-typed language, which means that the types of variables are known at compile-time. However, Julia also supports multiple dispatch, allowing functions to have different behavior based on the types of their arguments.

Now, let's examine the function signature:

function dms_to_decimal(coords::AbstractString)
  • function is the keyword used to define a function in Julia.
  • dms_to_decimal is the name of the function.
  • coords is the name of the argument that the function takes.
  • ::AbstractString is a type annotation specifying that the coords argument must be a subtype of AbstractString.

In this case, AbstractString is an abstract type in Julia that represents any string-like type. It is a supertype of concrete string types such as String (for ASCII strings) and UTF8String (for UTF-8 encoded strings).

By specifying coords::AbstractString, the function signature indicates that the coords argument must be a string or any subtype of AbstractString. This provides a way to enforce type constraints on the argument.

For someone coming from a duck-typed language, this type annotation might seem unusual. In duck-typed languages, you would typically just pass an argument to a function without explicitly specifying its type. The function would then operate on the argument based on its expected behavior.

However, in Julia, the type annotation ::AbstractString serves as a contract or a guarantee that the coords argument will behave like a string. It allows the Julia compiler to perform type checks and optimizations based on the specified type.

If you try to pass an argument that is not a subtype of AbstractString to the dms_to_decimal function, Julia will raise a type error at compile-time or runtime, depending on the situation.

Here's an example of how you would use this function in Julia:

coordinates = "41° 15′ 31″ N, 95° 56′ 15″ W"
result = dms_to_decimal(coordinates)

In this case, the coordinates variable is a string literal, which is a subtype of AbstractString, so it can be passed to the dms_to_decimal function without any issues.

To summarize, the ::AbstractString type annotation in the function signature specifies that the coords argument must be a string or any subtype of AbstractString. This allows Julia to perform type checks and ensures that the function is used correctly with the expected type of argument.

Mascot of the Day

The invention of duck typing stability.ai

Latest