Mapping Nearby Stars
ex_astro =
if File.exists?(Path.expand("../mix.exs", __DIR__)) do
{:ex_astro, path: Path.expand("..", __DIR__)}
else
{:ex_astro, "~> 0.3"}
end
Mix.install([ex_astro, {:kino, "~> 0.16"}])
Section
This notebook turns a 10-parsec star catalog into a rotating SVG map using
ex_astro. Everything astronomical in it — proper-motion propagation,
astrometry-to-state conversion — is a library call; the rest is plain Elixir
and a bit of SVG.
These are not artist’s impressions: every dot is a real BCRS position, propagated from the catalog epoch to the scene epoch and rotated into the J2000 ecliptic. The animation is a rigid rotation about the ecliptic pole, so the timing is exact — unlike the orbit diagrams, there is no uniform-$E$ approximation to confess.
The notebook is organised as a tutorial. The first half walks through the library: how a catalog row becomes a Cartesian state. The second half builds the map in stages — first face-on, then through a tilted camera, then animated — so each drawing technique is visible on its own before they combine into the finished scene.
On first run it downloads the 25 August 2023 update of table A1 from
Reylé et al. 2021,
The 10 parsec sample in the Gaia era, to ~/.cache/ex_astro/catalogs/. The
notebook verifies the catalog’s SHA-256 digest and writes nearby-stars.svg
next to itself.
The NIF build needs a C toolchain and liberfa at link time — inside this
repo’s flake: nix develop, then open the notebook in Livebook.
Load the 10 parsec catalog
Reylé et al. 2021 is a census of every star, brown dwarf, and exoplanet
within 10 parsecs, assembled in the Gaia era and published as VizieR
catalogue J/A+A/650/A201. Table A1 is a fixed-width ASCII dump: one object
per row, fields at published byte columns, no delimiters. The 25 August 2023
update is what this notebook pins by sha256.
Two reductions happen before any astronomy does. Planets are dropped — Jupiter-mass companions are in the table, but they are not stellar systems and would otherwise appear as extra dots. Multiple stars in one catalogued system are then collapsed to a single representative: the member with the brightest available V or Gaia magnitude. Alpha Centauri A, B, and Proxima share a system id; keeping all three would stack three overlapping dots, and the primary is the one the eye should weigh.
The parser slices columns with 1-based inclusive ranges from the VizieR
ReadMe (field(line, first, last) is binary_part plus trim). RA / Dec /
epoch / parallax are required; proper motion and radial velocity default to
zero when blank; magnitude walks V, then Gaia $G$, then a Gaia estimate, then
a faint fallback of 20.
defmodule NearbyStars.Catalog do
@moduledoc false
@cache Path.expand("~/.cache/ex_astro/catalogs/tablea1.dat")
@url "https://cdsarc.cds.unistra.fr/ftp/cats/J/A+A/650/A201/tablea1.dat.gz"
@sha256 "dcd8ed6d4d338ef1cafff867bbb3c85e0737d9082509311d80fd1cdfdf79e7f8"
def read! do
case File.read(@cache) do
{:ok, catalog} ->
verify!(catalog)
catalog
{:error, :enoent} ->
download!()
{:error, reason} ->
raise File.Error, reason: reason, action: "read file", path: @cache
end
end
defp download! do
IO.puts("downloading VizieR 10 pc catalog ...")
response = Req.get!(@url, raw: true, receive_timeout: 120_000, retry: false)
response.status == 200 || raise "GET #{@url} -> HTTP #{response.status}"
catalog = :zlib.gunzip(response.body)
verify!(catalog)
File.mkdir_p!(Path.dirname(@cache))
File.write!(@cache <> ".tmp", catalog)
File.rename!(@cache <> ".tmp", @cache)
catalog
end
defp verify!(catalog) do
actual =
catalog
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)
actual == @sha256 ||
raise "catalog sha256 mismatch: expected #{@sha256}, got #{actual}"
:ok
end
end
defmodule NearbyStars.Parse do
@moduledoc false
def systems(catalog) do
catalog
|> String.split("\n", trim: true)
|> Enum.map(&parse_row/1)
|> Enum.reject(&(&1.object_type == "Planet"))
|> Enum.group_by(& &1.system)
|> Enum.map(fn {_system, members} -> Enum.min_by(members, & &1.magnitude) end)
end
defp parse_row(line) do
visual = parse_float(field(line, 406, 412))
gaia = parse_float(field(line, 353, 361))
gaia_estimate = parse_float(field(line, 363, 368))
common_name = field(line, 545, 561)
catalog_name = field(line, 11, 39)
%{
system: parse_integer!(field(line, 6, 9)),
object_type: field(line, 41, 46),
ra: parse_float!(field(line, 79, 91)),
dec: parse_float!(field(line, 94, 106)),
epoch: parse_float!(field(line, 108, 113)),
parallax: parse_float!(field(line, 115, 122)),
pm_ra: parse_float(field(line, 165, 180)) || 0.0,
pm_dec: parse_float(field(line, 199, 214)) || 0.0,
radial_velocity: parse_float(field(line, 264, 271)) || 0.0,
magnitude: visual || gaia || gaia_estimate || 20.0,
name: if(common_name == "", do: catalog_name, else: common_name)
}
end
defp field(line, first, last) do
line
|> binary_part(first - 1, last - first + 1)
|> String.trim()
end
defp parse_float(""), do: nil
defp parse_float(value) do
case Float.parse(value) do
{number, ""} -> number
_ -> nil
end
end
defp parse_float!(value), do: parse_float(value) || raise("invalid float: #{inspect(value)}")
defp parse_integer!(value) do
case Integer.parse(value) do
{number, ""} -> number
_ -> raise "invalid integer: #{inspect(value)}"
end
end
end
catalog = NearbyStars.Catalog.read!()
systems = NearbyStars.Parse.systems(catalog)
{byte_size(catalog), length(systems)}
Propagate one star, then convert it to a BCRS state
The astronomical conversion is performed entirely through Astro.Star.
pmsafe/8 walks a catalog entry from its source epoch to the scene epoch;
starpv/6 turns the propagated astrometry into a BCRS Cartesian state in au
and au/day.
Parallax $\pi$ in arcseconds is the reciprocal of distance:
$$ d[\mathrm{pc}] = \frac{1}{\pi[\mathrm{arcsec}]} $$
The catalog stores milliarcseconds, so the code divides by $1000$ before the ERFA call. A star at $0.747$ arcsec is $1.34$ pc $\approx 4.37$ ly away — Alpha Centauri, after the collapse to the brightest member.
pmsafe is the safe proper-motion propagator (eraPmsafe). The ordinary
routine divides by parallax on the way to a space-velocity; a tiny or
zero $\pi$ (or a proper motion that implies an absurd transverse speed)
blows up. The safe variant substitutes a floor distance and reports
:distance_overridden rather than returning garbage. Nearby stars in this
catalog are safely measured, but the function is the one you want for mixed
catalogues.
Epochs are two-part TDB Julian Dates {jd1, jd2} — the same form
Astro.Time uses, so the full date is jd1 + jd2 without burning a 64-bit
float on a number near $2.45 \times 10^6$. Both calls here pin jd1 at
J2000.0 ($2451545.0$) and put the offset in jd2:
- source: $(t_{\mathrm{yr}} - 2000) \times 365.25$ days from the catalog epoch
-
scene:
Astro.Time.to_et(epoch) / 86400— ET seconds past J2000, as days
The catalog’s pmRA is already the projected rate
$\mu_{\alpha*} = \dot\alpha \cos\delta$, which is what you plot on the sky.
ERFA wants the coordinate rate $\dot\alpha = d\alpha/dt$, so the code
divides by $\cos\delta$ before converting mas/yr to rad/yr. Forgetting that
step shears every star toward the poles.
epoch = ~U[2026-08-14 00:00:00Z]
deg = :math.pi() / 180.0
mas_to_rad = :math.pi() / (180.0 * 3_600.0 * 1_000.0)
au_per_ly = 63_241.077
# After collapsing each catalogued system to its brightest member, the
# nearest neighbour is Alpha Centauri A (Rigil Kentaurus). Proxima is
# in that same system.
nearest = Enum.max_by(systems, & &1.parallax)
ra = nearest.ra * deg
dec = nearest.dec * deg
# The catalog's pmRA already includes cos(dec); ERFA expects dRA/dt.
pm_ra = nearest.pm_ra / :math.cos(dec) * mas_to_rad
pm_dec = nearest.pm_dec * mas_to_rad
parallax = nearest.parallax / 1_000.0
source_epoch = {2_451_545.0, (nearest.epoch - 2000.0) * 365.25}
scene_epoch = {2_451_545.0, Astro.Time.to_et(epoch) / 86_400.0}
unwrap = fn
{:ok, value}, _op ->
value
{:ok, value, warnings}, op ->
IO.warn("#{op} #{nearest.name}: #{inspect(warnings)}")
value
{:error, reason}, op ->
raise "#{op} failed for #{nearest.name}: #{inspect(reason)}"
end
{ra2, dec2, pm_ra2, pm_dec2, parallax2, radial_velocity2} =
unwrap.(
Astro.Star.pmsafe(
ra,
dec,
pm_ra,
pm_dec,
parallax,
nearest.radial_velocity,
source_epoch,
scene_epoch
),
:pmsafe
)
[x, y, z, vx, vy, vz] =
unwrap.(
Astro.Star.starpv(ra2, dec2, pm_ra2, pm_dec2, parallax2, radial_velocity2),
:starpv
)
distance_ly = :math.sqrt(x * x + y * y + z * z) / au_per_ly
%{
name: nearest.name,
catalog_epoch: nearest.epoch,
distance_ly: distance_ly,
position_au: {x, y, z},
velocity_au_day: {vx, vy, vz}
}
starpv (eraStarpv) is the second half: ICRS astrometry in, BCRS
barycentric position (au) and velocity (au/day) out. The 100 systems nearest
after this propagation fill a sphere about 20.4 light-years in radius.
From ICRS to cylindrical ecliptic coordinates
starpv returns a vector in the ICRS equatorial frame. The map wants the
J2000 ecliptic, so that the animation axis is the ecliptic pole — the same
pole the orbit diagrams revolve about. The IAU 1976 mean obliquity of J2000
is $\varepsilon = 23.4392911^\circ$; the rotation about $+x$ is
$$ \begin{pmatrix} x’ \ y’ \ z’ \end{pmatrix} = \begin{pmatrix} 1 & 0 & 0 \ 0 & \cos\varepsilon & \sin\varepsilon \ 0 & -\sin\varepsilon & \cos\varepsilon \end{pmatrix} \begin{pmatrix} x \ y \ z \end{pmatrix} $$
The result is stored in cylindrical coordinates, not flattened:
$$ r_{xy} = \sqrt{x’^2 + y’^2}, \qquad \phi = \operatorname{atan2}(y’, x’), \qquad z = z’ $$
That is the whole reason the animation can be exact. Every star already
circles the ecliptic pole at fixed $r{xy}$ and $z$. Rotating $\phi$ is
uniform circular motion about that pole — a rigid rotation of the scene —
so a single SMIL rotate on the unit-circle point $(\cos\phi, \sin\phi)$
sweeps the true projected path. The orbit notebook uses the same
unit-circle-under-matrix() trick; there the parameter is eccentric
anomaly and uniform $E$ only approximates Kepler timing. Here the parameter
_is the rotation angle, and there is no timing error.
defmodule NearbyStars.Place do
@moduledoc false
@star_count 100
@au_per_ly 63_241.077
@deg :math.pi() / 180.0
@mas_to_rad :math.pi() / (180.0 * 3_600.0 * 1_000.0)
# IAU 1976 mean obliquity of J2000: ICRS to ecliptic, about +x.
@obliquity 23.4392911 * @deg
def nearest(systems, epoch) do
systems
|> Enum.map(&place(&1, epoch))
|> Enum.sort_by(& &1.distance)
|> Enum.take(@star_count)
end
defp place(star, epoch) do
ra = star.ra * @deg
dec = star.dec * @deg
pm_ra = star.pm_ra / :math.cos(dec) * @mas_to_rad
pm_dec = star.pm_dec * @mas_to_rad
parallax = star.parallax / 1_000.0
source_epoch = {2_451_545.0, (star.epoch - 2000.0) * 365.25}
scene_epoch = {2_451_545.0, Astro.Time.to_et(epoch) / 86_400.0}
{ra2, dec2, pm_ra2, pm_dec2, parallax2, radial_velocity2} =
unwrap(
Astro.Star.pmsafe(
ra,
dec,
pm_ra,
pm_dec,
parallax,
star.radial_velocity,
source_epoch,
scene_epoch
),
:pmsafe,
star.name
)
[x, y, z | _velocity] =
unwrap(
Astro.Star.starpv(ra2, dec2, pm_ra2, pm_dec2, parallax2, radial_velocity2),
:starpv,
star.name
)
{ex, ey, ez} = to_ecliptic({x / @au_per_ly, y / @au_per_ly, z / @au_per_ly})
Map.merge(star, %{
distance: :math.sqrt(ex * ex + ey * ey + ez * ez),
radius_xy: :math.sqrt(ex * ex + ey * ey),
phase: :math.atan2(ey, ex),
z: ez
})
end
defp unwrap({:ok, value}, _operation, _name), do: value
defp unwrap({:ok, value, warnings}, operation, name) do
IO.warn("#{operation} #{name}: #{inspect(warnings)}")
value
end
defp unwrap({:error, reason}, operation, name) do
raise "#{operation} failed for #{name}: #{inspect(reason)}"
end
defp to_ecliptic({x, y, z}) do
cosine = :math.cos(@obliquity)
sine = :math.sin(@obliquity)
{x, y * cosine + z * sine, -y * sine + z * cosine}
end
end
stars = NearbyStars.Place.nearest(systems, epoch)
{length(stars), List.first(stars).name, List.last(stars).distance}
The camera
The camera is the same orthographic projection the orbit diagrams use:
rotate the scene about the ecliptic pole by rotation (purely cosmetic —
it keeps interesting structure off the canvas axes), tilt it by tilt away
from face-on, and drop the depth coordinate. The y-sign flips because SVG’s
y-axis points down.
$$ \begin{aligned} x_1 &= x\cos\psi - y\sin\psi \ y_1 &= x\sin\psi + y\cos\psi \ (X, Y) &= \bigl(x_1,\; -(y_1\cos\theta - z\sin\theta)\bigr) \end{aligned} $$
The finished map uses $\psi = 15^\circ$ and $\theta = 66^\circ$, matching
the orbit scenes on purpose: the two notebooks sit next to each other
without a change of visual language. scaled_project/4 is that map times
the scene scale.
defmodule NearbyStars.Camera do
@moduledoc false
# Orthographic camera: scene rotation about the ecliptic pole, camera tilt
# from face-on, then drop depth. The y flip converts to SVG's downward y.
def project({x, y, z}, rotation, tilt) do
x1 = x * :math.cos(rotation) - y * :math.sin(rotation)
y1 = x * :math.sin(rotation) + y * :math.cos(rotation)
{x1, -(y1 * :math.cos(tilt) - z * :math.sin(tilt))}
end
def scaled_project(point, scale, rotation, tilt) do
{x, y} = project(point, rotation, tilt)
{x * scale, y * scale}
end
end
Drawing stars and shells
Each distance shell is a circle of radius $R$ ly in the ecliptic plane,
projected by sending $\vec p = P(R, 0, 0)$ and $\vec q = P(0, R, 0)$ into
matrix(px py qx qy 0 0) around <circle r="1"/>. Each star is the same
trick at that star’s $r_{xy}$, plus a translation $P(0, 0, z)$ so the
cylinder height survives the tilt. The dot itself is a zero-length
round-capped path (M x y l .0001 0) at $(\cos\phi, \sin\phi)$ on the unit
circle; vector-effect="non-scaling-stroke" keeps the stroke in screen
pixels so the ellipse matrix cannot stretch it.
Apparent magnitude is already logarithmic in flux ($F \propto 10^{-0.4 m}$). Mapping $m$ through a linear clamp onto stroke width and opacity is therefore a rough log-brightness scale, not a photometric one — enough to let Sirius outshine a late M dwarf without solving a PSF.
defmodule NearbyStars.Format do
@moduledoc false
def matrix(values), do: "matrix(#{Enum.map_join(values, " ", &format(&1, 2))})"
def escape(value) do
value
|> String.replace("&", "&")
|> String.replace("<", "<")
|> String.replace(">", ">")
end
def clamp(value, low, high), do: value |> max(low) |> min(high)
def format(value, 0) do
value
|> :erlang.float_to_binary(decimals: 0)
|> case do
"-0" -> "0"
number -> number
end
end
def format(value, decimals) do
value
|> :erlang.float_to_binary(decimals: decimals)
|> String.trim_trailing("0")
|> String.trim_trailing(".")
|> case do
"-0" -> "0"
number -> number
end
end
end
defmodule NearbyStars.Mark do
@moduledoc false
@rotation_s 90
def rotation_s, do: @rotation_s
def star(star, scale, rotation, tilt, animate?) do
{px, py} = NearbyStars.Camera.scaled_project({star.radius_xy, 0.0, 0.0}, scale, rotation, tilt)
{qx, qy} = NearbyStars.Camera.scaled_project({0.0, star.radius_xy, 0.0}, scale, rotation, tilt)
{ox, oy} = NearbyStars.Camera.scaled_project({0.0, 0.0, star.z}, scale, rotation, tilt)
transform = NearbyStars.Format.matrix([px, py, qx, qy, ox, oy])
width = NearbyStars.Format.clamp(4.2 - 0.22 * star.magnitude, 1.2, 4.2)
opacity = NearbyStars.Format.clamp(0.82 - 0.04 * star.magnitude, 0.22, 0.82)
x = NearbyStars.Format.format(:math.cos(star.phase), 5)
y = NearbyStars.Format.format(:math.sin(star.phase), 5)
if animate? do
"""
<g transform="#{transform}">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="#{@rotation_s}s" repeatCount="indefinite"/>
<path d="M #{x} #{y} l .0001 0" stroke-width="#{NearbyStars.Format.format(width, 2)}" stroke-linecap="round" stroke-opacity="#{NearbyStars.Format.format(opacity, 2)}" vector-effect="non-scaling-stroke">
<title>#{NearbyStars.Format.escape(star.name)} · #{NearbyStars.Format.format(star.distance, 2)} ly</title>
</path>
</g>
</g>\
"""
else
"""
<g transform="#{transform}">
<path d="M #{x} #{y} l .0001 0" stroke-width="#{NearbyStars.Format.format(width, 2)}" stroke-linecap="round" stroke-opacity="#{NearbyStars.Format.format(opacity, 2)}" vector-effect="non-scaling-stroke">
<title>#{NearbyStars.Format.escape(star.name)} · #{NearbyStars.Format.format(star.distance, 2)} ly</title>
</path>
</g>\
"""
end
end
def shell(light_years, scale, rotation, tilt) do
{px, py} = NearbyStars.Camera.scaled_project({light_years, 0.0, 0.0}, scale, rotation, tilt)
{qx, qy} = NearbyStars.Camera.scaled_project({0.0, light_years, 0.0}, scale, rotation, tilt)
matrix = NearbyStars.Format.matrix([px, py, qx, qy, 0.0, 0.0])
~s(<circle r="1" transform="#{matrix}" vector-effect="non-scaling-stroke"/>)
end
end
The renderer assembles the frame, the 5 ly shells, the pole line, the dots,
and the labels. Hover a path for the <title> tooltip. Stages below call
this with different cameras; only the last cell writes the file.
defmodule NearbyStars.Render do
@moduledoc false
@center 400.0
@font "ui-monospace, 'JetBrains Mono', 'Fira Code', monospace"
def scene(stars, scale, rotation, tilt, opts) do
epoch = Keyword.fetch!(opts, :epoch)
animate? = Keyword.get(opts, :animate, false)
title = Keyword.get(opts, :title, "100 NEAREST SYSTEMS · 5 LY RINGS")
aria = Keyword.get(opts, :aria, "The 100 nearest stellar systems in 3D")
desc = Keyword.get_lazy(opts, :desc, fn -> desc(stars, epoch) end)
dots = Enum.map_join(stars, "\n", &NearbyStars.Mark.star(&1, scale, rotation, tilt, animate?))
shells = Enum.map_join([5, 10, 15, 20], "\n", &NearbyStars.Mark.shell(&1, scale, rotation, tilt))
f = &NearbyStars.Format.format/2
"""
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" viewBox="0 0 800 800" role="img" aria-label="#{aria}">
<desc>
#{desc}
</desc>
<rect x="0.5" y="0.5" width="799" height="799" rx="12" fill="#050505" stroke="#1c1c1f"/>
<g transform="translate(#{f.(@center, 0)} #{f.(@center, 0)})" fill="none">
<g stroke="#303036" stroke-width="1">
#{shells}
</g>
<path d="M 0 #{f.(-22 * scale, 2)} V #{f.(22 * scale, 2)}" stroke="#26262b" stroke-dasharray="2 5"/>
<g stroke="#e6e6e9">
#{dots}
</g>
<circle r="4" fill="#e25d52"/>
<text x="9" y="4" fill="#b7b7bf" font-family="#{@font}" font-size="10">SOL</text>
</g>
<g font-family="#{@font}" font-size="11" letter-spacing="2.5" fill="#606069">
<text x="28" y="36" fill="#9d9da6" font-size="12">EX_ASTRO</text>
<text x="772" y="36" text-anchor="end">#{Calendar.strftime(epoch, "%Y-%m-%d")} UTC</text>
<text x="28" y="774">#{title}</text>
<text x="772" y="774" text-anchor="end">ERFA · REYLÉ+ 2021</text>
</g>
</svg>
"""
end
def desc(stars, epoch) do
furthest = NearbyStars.Format.format(List.last(stars).distance, 2)
"""
The 100 nearest stellar systems to Sol from Reyle et al. 2021,
A&A 650 A201, VizieR J/A+A/650/A201 tablea1 update 25-Aug-2023.
One representative (the brightest catalogued member) is retained per
system. Astrometry is propagated to #{epoch} with ERFA eraPmsafe and
eraStarpv through ex_astro, rotated from ICRS to the J2000 ecliptic,
and orthographically projected with a 66 degree camera tilt. The scene
revolves about the ecliptic pole once every #{NearbyStars.Mark.rotation_s()} seconds. Sol is
centered; the outermost system is #{furthest} light-years away.
Generated by examples/stars.livemd; do not edit by hand.\
"""
end
end
Stage 1: the face-on view
The simplest camera looks straight down the ecliptic pole: rotation 0, tilt 0, so the projection is just $(x, y, z) \mapsto (x, -y)$ and the drawing is a true top-down chart of the solar neighbourhood in the ecliptic plane.
Height $z$ is invisible from here. A star 8 ly above the plane at $r_{xy} = 3$ ly sits on the 3 ly circle, not the 8.5 ly sphere it actually occupies. The 5 ly rings are true circles. Scale is the same one the finished map uses — furthest system to 310 px — so later tilts are comparable, not re-fitted.
scale = 310.0 / List.last(stars).distance
face_on =
NearbyStars.Render.scene(stars, scale, 0.0, 0.0,
epoch: epoch,
animate: false,
title: "FACE-ON · ECLIPTIC PLANE",
aria: "Face-on map of the 100 nearest stellar systems",
desc:
" Face-on ecliptic chart of the 100 nearest systems (tilt 0, static).\n Generated by examples/stars.livemd."
)
Kino.HTML.new(face_on)
Stage 2: tilting the camera
A face-on map is honest but flat. Tilting the camera away from the pole
turns $z$ into a vertical screen offset, and the distance shells become
ellipses — still exact, because the projection is still linear and the
unit-circle matrix() still holds.
The finished scene uses two fixed angles: the scene rotated 15° about the pole and the camera tilted 66° from face-on, the same pair as the orbit diagrams. The tabs below keep that 15° rotation and step the tilt through 0°, 33°, and 66° so the depth shows up incrementally.
rad = :math.pi() / 180.0
tilted = fn tilt_deg ->
NearbyStars.Render.scene(stars, scale, 15.0 * rad, tilt_deg * rad,
epoch: epoch,
animate: false,
title: "SOLAR NEIGHBOURHOOD · TILT #{tilt_deg}°",
aria: "Nearby stars at #{tilt_deg} degree camera tilt",
desc:
" Nearby stars, scene rotated 15 deg, camera tilted #{tilt_deg} deg, static.\n Generated by examples/stars.livemd."
)
end
Kino.Layout.tabs(
"Face-on": Kino.HTML.new(tilted.(0)),
"Tilted 33°": Kino.HTML.new(tilted.(33)),
"Tilted 66°": Kino.HTML.new(tilted.(66))
)
Stage 3: setting it in motion
Animation falls out of the cylindrical placement. The dot sits at angle
$\phi$ on the unit circle inside the matrix group — so rotating that
circle underneath the matrix sweeps the star around the projected shell at
its true $r_{xy}$ and $z$. One SMIL <animateTransform type="rotate"> per
star does it, no JavaScript, and it keeps playing inside <img> tags and
GitHub’s image proxy, where scripts are stripped.
This is a stricter result than the orbit animation. Those dots rotate uniformly in eccentric anomaly, which only approximates Kepler timing (the path and period are exact; the rate is off by up to $\pm e$). Here the motion is a rigid rotation of the scene about the ecliptic pole: every star shares the same angular rate, $r_{xy}$ and $z$ never change, and there is no timing error. The 90 s period is a viewing choice, not a physical one.
animated =
NearbyStars.Render.scene(stars, scale, 15.0 * rad, 66.0 * rad,
epoch: epoch,
animate: true,
title: "SOLAR NEIGHBOURHOOD · ANIMATED",
aria: "Rotating map of the 100 nearest stellar systems",
desc:
" Nearby stars, 15 deg rotation, 66 deg tilt, revolving once every 90 s.\n Generated by examples/stars.livemd."
)
Kino.HTML.new(animated)
The finished map
The committed diagram is the stage-3 camera — 15° rotation, 66° tilt, 90 s
revolution — with the provenance <desc> and the original footer. It is
written next to this notebook.
defmodule NearbyStars.Scene do
@moduledoc false
@out Path.join(__DIR__, "nearby-stars.svg")
@deg :math.pi() / 180.0
@scene_rotation 15.0 * @deg
@camera_tilt 66.0 * @deg
@fit_radius 310.0
def render(stars, epoch) do
scale = @fit_radius / List.last(stars).distance
svg =
NearbyStars.Render.scene(stars, scale, @scene_rotation, @camera_tilt,
epoch: epoch,
animate: true
)
File.write!(@out, svg)
%{path: @out, svg: svg, stars: stars, scale: scale}
end
end
scene = NearbyStars.Scene.render(stars, epoch)
{length(stars), List.first(stars).name, List.last(stars).distance}
The nearest dozen, after propagation to the scene epoch:
Kino.DataTable.new(
stars
|> Enum.take(12)
|> Enum.map(fn star ->
%{
system: star.name,
distance_ly: Float.round(star.distance, 2),
magnitude: Float.round(star.magnitude, 2),
z_ly: Float.round(star.z, 2)
}
end)
)
The rotating map — hover a dot for the system name and distance:
Kino.HTML.new(scene.svg)
scene.path