SVG sky map with Ruby and Astronoby

Ever wondered how the sky looked like when you were born? Don't just watch it, build it, easily!

While I have plenty of ideas for Astronoby before I release v1.0, it is already mature enough for a lot of use cases.

One of them that I've been wanting to experiment with for a while is generating an SVG sky map for a specific date, time and location.
There are already plenty of websites with their own sky map, but as always what I like is understanding how it works, being able to do it myself, and more importantly offer an open source version for the community.

Step 1: Map and projection

Let's start simple, no Ruby, no dynamic data, just the map and a first star on it. Our SVG will sit in a 400px bordered square:

html
<svg
  viewBox="0 0 400 400"
  width="400"
  height="400"
  style="border: #ff0000 solid 1px"
></svg>

The viewBox attribute defines the SVG's own coordinate system, from 0 to 400 on both axes. It is independent of the size on screen, so the drawing scales if the SVG is resized instead of being cropped.

Then, the sky itself. I'm pretty sure you already visualise a disk, and that's what we need, but let's take just one minute to think why a sky map is often represented as a disk.

Projections

If you go out, in the countryside, walk in a large field with nothing too tall next to you, you can see the horizon in front of you. And behind you. All around you actually. The whole horizon is a perfect 360° circle, and you're at the centre of it.

Then, you look up, at the zenith. Starting from the horizon, your gaze traces an arc up to the zenith. And if the ground were transparent, you would be able to look at the point that's exactly below you, the opposite of the zenith, the nadir.
The whole sky is a sphere around you, but only the half above the horizon is visible. You're literally the centre of the universe, yours.

Now, how do we go from a hemisphere to a two-dimensional representation? There is no perfect projection, you'll have to compromise on something, either the angles, the distances, or the areas.
With the horizon being a circle, it makes it natural to represent the sky as a disk. Beyond that, the choice of projection is arbitrary. Stereographic to keep angles and shapes, equidistant to keep distances from the zenith accurate, and others. Each projection has its own trade-offs, and there is no universally “correct” one. We'll use azimuthal equidistant here.

Dark sky

Let's draw the celestial hemisphere as a beautiful dark disk. SVG uses the circle element with the following attributes:

  • cx: location of the centre on the x axis
  • cy: location of the centre on the y axis
  • r: size of the radius
  • fill: background colour
  • stroke: border colour
  • stroke-width: border width
html
<svg
  viewBox="0 0 400 400"
  width="400"
  height="400"
  style="border: #ff0000 solid 1px"
>
  <circle
    cx="200"
    cy="200"
    r="198.5"
    stroke="#3262a8"
    fill="#000000"
    stroke-width="3"
  />
</svg>

I'll remove the border in the next example now that we've seen what the viewport looks like. Note that the radius is not exactly half the SVG size, it leaves enough space for the border to fit.

First star

For the first star, we're going to use a simplified version of Polaris, or α Ursae Minoris, as if it was exactly the north celestial pole.

As the celestial pole, its coordinates don't change across the day or the year. Its azimuth, the direction around the horizon, is zero. And its altitude, the height above the horizon, is the latitude of the observer. I live in Paris, latitude 48.8575° North, so the celestial pole is always exactly 48.8575° above the horizon, on the North-South axis.

So, we already have cx = center_x = 200, and we need a small calculation to convert the altitude from degrees to cy:

cy = center_y - radius * (90 - altitude) / 90

cy = 200 - 198.5 * (90 - 48.8575) / 90
cy = 109.258

There is no specific maths involved, all we calculate is how far from the centre (the zenith) we need to draw the star, based on the SVG's size and the location of the centre.

Now that we have coordinates, all we need is to draw the star:

html
<svg viewBox="0 0 400 400" width="400" height="400">
  <circle
    cx="200"
    cy="200"
    r="198.5"
    stroke="#3262a8"
    fill="#000000"
    stroke-width="3"
  />
  <circle cx="200" cy="109.258" r="1" fill="#ffffff" />
</svg>

Step 2: Real stars

Now that we have a sky background and the ability to draw stars on it, we need to support not only their altitude but also their azimuth.

We are going to use actual coordinates of the main stars of two constellations, Ursa Major and Ursa Minor, seen from Paris on 24 September 2026, at 22:00 UTC (midnight local time). You'll have to trust me on these, but don't worry, we will see how to generate these as well later in the post.

# (altitude, azimuth)

# Ursa Major
(20.769, 354.809)
(15.410, 354.390)
(14.187, 346.314)
(18.277, 344.282)
(19.297, 338.649)
(20.346, 334.149)
(17.429, 327.801)

# Ursa Minor
(49.061, 0.897)
(49.624, 354.867)
(48.876, 347.805)
(45.296, 342.678)
(46.410, 339.003)
(41.192, 336.391)
(40.703, 340.566)

We already have the calculation for the altitude, but it was actually a simplified formula due to our fake star being at the celestial pole. The azimuth, which in terms of SVG is the angle around the centre, will enable us to compute cx and cy based on the altitude. This time, we won't be able to avoid some trigonometry.

On the trigonometric circle, the sine and cosine of the azimuth give the horizontal and vertical components. We then need to use the altitude (we'll call it distance because it's actually the distance from the centre) and the centre's coordinates.

distance = radius * (90 - altitude) / 90
horizontal = center_x - distance * sin(azimuth)
vertical = center_y - distance * cos(azimuth)

Note we're using - and not +, following the astronomical convention of having East on the left and West on the right. It may look reversed compared to a map, but a sky map is meant to be held above your head while facing south.
Azimuth is measured clockwise from north, which also explains why the sine function is used for the horizontal value. This linear formula is our azimuthal equidistant projection: a star at 45° altitude is located halfway to the edge.

If you're trying to do this calculation yourself at this point, remember that the given altitude and azimuth coordinates are angles in decimal degrees, while the trigonometric functions expect angles in radians, so you'll have to convert them this way: radians = degrees * π / 180.

That gives us the following markup:

html
<svg viewBox="0 0 400 400" width="400" height="400">
  <circle
    cx="200"
    cy="200"
    r="198.5"
    stroke="#3262a8"
    fill="#000000"
    stroke-width="3"
  />
  <!-- Ursa Major -->
  <circle cx="213.8" cy="47.9" r="1" fill="#ffffff" />
  <circle cx="216.1" cy="36.3" r="1" fill="#ffffff" />
  <circle cx="239.6" cy="37.5" r="1" fill="#ffffff" />
  <circle cx="242.9" cy="47.7" r="1" fill="#ffffff" />
  <circle cx="256.8" cy="54.8" r="1" fill="#ffffff" />
  <circle cx="267.0" cy="61.7" r="1" fill="#ffffff" />
  <circle cx="285.3" cy="64.6" r="1" fill="#ffffff" />
  <!-- Ursa Minor -->
  <circle cx="198.6" cy="109.7" r="1" fill="#ffffff" />
  <circle cx="208.0" cy="111.3" r="1" fill="#ffffff" />
  <circle cx="219.2" cy="111.3" r="1" fill="#ffffff" />
  <circle cx="229.4" cy="105.9" r="1" fill="#ffffff" />
  <circle cx="234.4" cy="110.2" r="1" fill="#ffffff" />
  <circle cx="243.1" cy="101.4" r="1" fill="#ffffff" />
  <circle cx="236.2" cy="97.5" r="1" fill="#ffffff" />
</svg>

If you're familiar with the northern night sky, you probably recognise the Big and Little Dippers. You may also notice that Polaris, the first star of Ursa Minor, isn't exactly where we drew it in the first step: this time we're using its real position, slightly off the celestial pole.

Step 3: Scaling with Ruby and Astronoby

This time, we're doing it for real: lots of real stars, from a dynamic location on Earth, on a selected day and time.

Star catalogue

There are hundreds of billions of stars in our galaxy and only a few thousand visible with the naked eye at once, even under exceptional conditions. In such a sky chart, the goal is not to cover all the theoretical sky but to have an accurate representation of what we can see.

We could interface with the whole Bright Star Catalogue, Hipparcos catalogue or Gaia, but to keep it simple in this blog post we're going to use a custom catalogue of 909 stars: everything brighter than magnitude 4.5, plus the fainter ones needed to complete some constellations. I've uploaded it as a CSV file stars.csv, feel free to use it.

For now, the important information for us is right_ascension and declination, they are coordinates of the star in a specific reference frame and a specific coordinate system. I won't go into details in this article, Astronoby is going to do most of the job for us.

Accurate coordinates with Astronoby

I'm not here to brag, but I think Astronoby is pretty great. It doesn't look that way but the sky is constantly changing, stars move and the Earth changes orientation. Star catalogues usually provide coordinates in J2000 reference frame, which means we need to apply multiple corrections to these coordinates to compute the accurate ones for a given date and time. And then, multiple transformations are necessary to get the proper horizontal coordinates for a specific observer.

All this is done by Astronoby and very little code will be required from us to achieve this goal.

Let's first define our observer. For this article I'm going to look at the sky from Paris, but you just have to change the values for your own location. I'm also setting a specific time for our example, but if you want a "live" version just use Time.now. Finally, we'll say the CSV star catalogue is available in the same directory.

rb
require "csv"
require "astronoby"

observer = Astronoby::Observer.new(
  latitude: Astronoby::Angle.from_degrees(48.8575),
  longitude: Astronoby::Angle.from_degrees(2.3522)
)

time = Time.utc(2026, 9, 24, 22)
instant = Astronoby::Instant.from_time(time)

catalogue = File.expand_path("stars.csv", __dir__)

Everything is ready, we can now compute the horizontal coordinates of every star:

rb
stars = CSV.read(catalogue, headers: true).map do |row|
  Astronoby::DeepSkyObject
    .new(
      equatorial_coordinates: Astronoby::Coordinates::Equatorial.new(
        right_ascension: Astronoby::Angle.from_hours(row["right_ascension"].to_f),
        declination: Astronoby::Angle.from_degrees(row["declination"].to_f),
        epoch: Astronoby::JulianDate::J2000
      )
    )
    .at(instant)
    .observed_by(observer)
    .horizontal
end

Let's briefly explain what's going on here. We first instantiate a DeepSkyObject object with the J2000 coordinates provided in the CSV catalogue. Then, we let Astronoby transform the representation of the object to our specified date and time. Then, Astronoby converts it to a topocentric reference frame, meaning the object now describes the star as seen from the observer. Finally, we extract the horizontal coordinates, from which we can later access altitude and azimuth.

That's done for the astronomy part. What's remaining is building the SVG markup. We already covered the calculation, so I'll go straight to the code.

rb
RADIUS = 198.5
CENTER = 200

points = stars.filter_map do |horizontal|
  next if horizontal.altitude.negative?

  r = RADIUS * (90 - horizontal.altitude.degrees) / 90
  cx = CENTER - r * horizontal.azimuth.sin
  cy = CENTER - r * horizontal.azimuth.cos

  [cx, cy]
end

We have all our points to draw in the SVG. Note that I'm skipping all the stars that are not visible because below the horizon, we only want to draw stars that are visible to us.

Final part, we're going to write the markup into an HTML file. This is definitely not the best way to generate HTML, but that will do for our example.

rb
CANVAS = 400
OUTPUT = File.expand_path("sky_map.html", __dir__).freeze
BACKGROUND_COLOR = "#000000"
HORIZON_COLOR = "#3262a8"
STAR_COLOR = "#ffffff"

circles = points.map do |cx, cy|
  %(  <circle cx="#{cx.round(2)}" cy="#{cy.round(2)}" r="1" fill="#{STAR_COLOR}" />)
end

File.write(OUTPUT, <<~SVG)
  <svg viewBox="0 0 #{CANVAS} #{CANVAS}" width="#{CANVAS}" height="#{CANVAS}">
    <circle cx="#{CENTER}" cy="#{CENTER}" r="#{RADIUS}" stroke="#{HORIZON_COLOR}" fill="#{BACKGROUND_COLOR}" stroke-width="3"/>
    #{circles.join("\n")}
  </svg>
SVG

That generates a 400+ lines SVG:

html
<svg viewBox="0 0 400 400" width="400" height="400">
  <circle
    cx="200"
    cy="200"
    r="198.5"
    stroke="#3262a8"
    fill="#000000"
    stroke-width="3"
  />
  <circle cx="149.06" cy="235.19" r="1" fill="#ffffff" />
  <circle cx="127.85" cy="209.58" r="1" fill="#ffffff" />
  <circle cx="117.34" cy="182.59" r="1" fill="#ffffff" />
  <circle cx="136.18" cy="225.99" r="1" fill="#ffffff" />
  <!-- ... -->
  <circle cx="295.63" cy="233.81" r="1" fill="#ffffff" />
</svg>

That's plenty of stars and, in my opinion, it looks like a nighttime dark sky. But we can do even better.

Step 4: Magnitudes and the Moon

You probably have noticed that some stars seem brighter than others (and from a city, light pollution hides most of them), this is what we call magnitude, the apparent brightness of a star from our point of view.

We already have the magnitude available in the CSV catalogue, so we only need to adapt our script and draw different sizes of stars based on their magnitude so that they look more "visible". It will also be easier to recognise the constellations from their famous shapes we call asterisms.

rb
MAG_LIMIT = 5.5
MAG_RANGE = 7.0
MAG_EXPONENT = 1.5
MAX_STAR_SIZE = 5.5
MIN_STAR_SIZE = 0.45

sky_objects = CSV.read(catalogue, headers: true).map do |row|
  magnitude = row["magnitude"].to_f

  horizontal = Astronoby::DeepSkyObject
    .new(
      equatorial_coordinates: Astronoby::Coordinates::Equatorial.new(
        right_ascension: Astronoby::Angle.from_hours(row["right_ascension"].to_f),
        declination: Astronoby::Angle.from_degrees(row["declination"].to_f),
        epoch: Astronoby::JulianDate::J2000
      )
    )
    .at(instant)
    .observed_by(observer)
    .horizontal

  brightness = ((MAG_LIMIT - magnitude) / MAG_RANGE).clamp(0.0, 1.0)
  size = MAX_STAR_SIZE * brightness**MAG_EXPONENT

  {
    horizontal: horizontal,
    size: [size, MIN_STAR_SIZE].max
  }
end

points = sky_objects.filter_map do |sky_object|
  next if sky_object[:horizontal].altitude.negative?

  r = RADIUS * (90 - sky_object[:horizontal].altitude.degrees) / 90
  cx = CENTER - r * sky_object[:horizontal].azimuth.sin
  cy = CENTER - r * sky_object[:horizontal].azimuth.cos

  [cx, cy, sky_object[:size]]
end

circles = points.map do |cx, cy, size|
  %(  <circle cx="#{cx.round(2)}" cy="#{cy.round(2)}" r="#{size.round(3)}" fill="#{STAR_COLOR}" />)
end

File.write(OUTPUT, <<~SVG)
  <svg viewBox="0 0 #{CANVAS} #{CANVAS}" width="#{CANVAS}" height="#{CANVAS}">
    <circle cx="#{CENTER}" cy="#{CENTER}" r="#{RADIUS}" stroke="#{HORIZON_COLOR}" fill="#{BACKGROUND_COLOR}" stroke-width="3"/>
    #{circles.join("\n")}
  </svg>
SVG

Magnitude is an awkward scale. It runs backwards, the lower the number the brighter the star, and it's logarithmic: each step down is 2.512 times more light. Our formula squeezes the scale into 0 to 1, with a maximum and minimum magnitude defined from our star catalogue. The MAG_EXPONENT ensures faint stars (the majority of the catalogue) are not too visible.

From magnitude to brightness and then size, we define the radius of the star disks.

This is not perfect but it starts looking really like a nighttime sky.

Let's have a small bonus: that night, at this time, the Moon was visible in Paris' night sky, so let's add it:

rb
MOON_SIZE = 6
MOON_COLOR = "#f0e6c8"

ephem = Astronoby::Ephem.load(File.expand_path("inpop19a.bsp", __dir__))
time = Time.utc(2026, 9, 24, 22)
instant = Astronoby::Instant.from_time(time)

moon = Astronoby::Moon
  .new(ephem: ephem, instant: instant)
  .observed_by(observer)

sky_objects = CSV.read(catalogue, headers: true).map do |row|
  # ...

  {
    horizontal: horizontal,
    size: [size, MIN_STAR_SIZE].max,
    color: STAR_COLOR
  }
end

sky_objects += [{
  size: MOON_SIZE,
  color: MOON_COLOR,
  horizontal: moon.horizontal
}]

points = sky_objects.filter_map do |sky_object|
  # ...

  [cx, cy, sky_object[:size], sky_object[:color]]
end

# ...

circles = points.map do |cx, cy, size, color|
  %(  <circle cx="#{cx.round(2)}" cy="#{cy.round(2)}" r="#{size.round(3)}" fill="#{color}" />)
end

Astronoby requires an ephemeris file to accurately compute the Moon's topocentric coordinates, if you are interested you can check Astronoby's documentation.

The Moon's location on the sky map follows the same calculation as stars, we just added a colour and renamed a few variables so that the code still makes sense. This adds a large (deliberately exaggerated) disk in the sky when the Moon is visible.

Conclusion and what's next

It didn't take us much to build a decent and precise SVG representation of the night sky for a specific date and time from a specific location. SVG <circle> tags, a little bit of trigonometry and Astronoby for the coordinates transformation.

We could go way further to have a complete sky map, for example it would be nice to have asterism lines to recognise constellations easily, but also the names of the constellations and the main stars, planets, cardinal directions, a scale of the azimuth, etc.

This is what I want to build in a new Ruby gem I started a few weeks ago: skymap. My goal is to build a nice Ruby interface and CLI to enable people to generate an accurate and customisable map for the date and location of their choice, in open source as always.

Don't hesitate to join, contribute or add issues to the project, I am always eager to share my passion with others and to learn from the community.

Back to all articles