A Star Map for Any Date, Drawn in the Browser
How the Polyatic star map turns a date, a time and a place into the sky that was overhead: the 1,630-star catalogue it ships, the four coordinate steps from that catalogue to the paper (with the real function names), how constellation lines and names are placed, the SVG, PNG, PDF and laser-cut exports, and what never leaves your browser.
📅
Positional astronomy / SVG / client-side
The Polyatic star map takes a date, a time and a place and draws the stars that were above that place at that moment, as a printable circular chart. There is no server-side rendering: the page downloads a star catalogue once, and every step from “21:00 on 15 January in London” to a dot on paper runs in JavaScript in your tab.
- Code: plain ES modules, no bundler, no build step; the astronomy lives in
js/astro.jsand the drawing injs/render.js - Stars: 1,630, every star in the Yale Bright Star Catalogue down to visual magnitude 5.0
- Figures: 89 constellation stick figures, 150 line segments, 893 vertices
- Exports: SVG, PNG at 300 dpi, a print-size PDF, and a laser “cut & engrave” SVG sized in millimetres
- Languages: the app in 3, the poster text in 8, and 49 landing pages
- Dependencies: one,
pdf-lib, loaded only when you press Download PDF
Every number on this page was measured on 2026-10-09 against the starmap repository at commit feff733, with the command shown beside it, run from the repository root.
The catalogue it ships
The stars are the Yale Bright Star Catalogue (5th revised edition, Hoffleit and Warren 1991), reduced to one JSON file, data/bright-stars.json. It is public domain. The reduction kept, for each star, its J2000 right ascension and declination in decimal degrees, its visual magnitude, its colour index and, where the IAU has one, its proper name.
$ wc -c < data/bright-stars.json
126516
$ gzip -9c data/bright-stars.json | wc -c
35525
$ node -e 'const s=require("./data/bright-stars.json").stars; console.log(s.length, Math.min(...s.map(x=>x.mag)), Math.max(...s.map(x=>x.mag)), s.filter(x=>x.name).length)'
1630 -1.46 5 307
So: 126,516 bytes on disk (35,525 gzipped), 1,630 stars, brightest Sirius at magnitude −1.46, faintest exactly at the 5.0 cut, and 307 of them carrying a name. Magnitude 5.0 is roughly what a suburban sky shows on a good night; a dark rural site shows stars a full magnitude fainter that this chart does not have.
The stick figures come from Olaf Frohn’s d3-celestial (BSD-3-Clause), stored the same way: each line is a list of [ra, dec] vertices in J2000 degrees, not a list of star IDs, so a figure goes through exactly the same arithmetic as a star.
$ node -e 'const c=require("./data/constellations.json").constellations; console.log(c.length, c.reduce((a,x)=>a+x.lines.length,0), c.reduce((a,x)=>a+x.lines.reduce((b,s)=>b+s.length,0),0))'
89 150 893
Step 1: the date, the time and the place become one instant
A wall-clock time means nothing to the sky until it is pinned to an instant. When you pick a town, the page knows its IANA time zone (from a GeoNames-derived town list, CC BY 4.0, shipped in data/towns.json) and asks the browser’s own time-zone database for the UTC offset at that date. resolveWallTime in js/link-place.js does it by trying each offset in force within a day either side and keeping the ones that map back onto the typed time, which is how it notices the hour that happened twice in autumn and the hour that never happened in spring. Typed coordinates with no town fall back to a longitude guess, and the offset field stays editable.
The date also picks a calendar. effectiveCalendar in js/astro.js reads anything before 15 October 1582 as a Julian-calendar date, because that is what a date written in 1066 means. Then localToJulianUTC turns the fields into a Julian Date in UTC, a running day count used across astronomy:
export function localToJulianUTC(year, month, day, hour, minute, second,
utcOffsetHours = 0, calendar = "gregorian") {
return julianDate(year, month, day, hour, minute, second, calendar)
- utcOffsetHours / 24;
}
julianDate is Meeus’s chapter 7 formula, written with floor divisions so it works for any year the form accepts. The page accepts years 1 to 3000 (CHART_YEAR_MIN and CHART_YEAR_MAX in js/app.js) and says so in a sentence outside that range instead of drawing something wrong.
Step 2: the instant becomes a sidereal time
The sky turns once relative to the stars in a little under 24 hours, so the second step is sidereal time: the right ascension that is crossing your meridian. gmst is the IAU 1982 polynomial for Greenwich Mean Sidereal Time, and lst adds your longitude:
export function gmst(jd) {
const d = jd - 2451545.0;
const T = d / 36525.0;
let g = 280.46061837 + 360.98564736629 * d
+ 0.000387933 * T * T - (T * T * T) / 38710000.0;
return mod360(g);
}
export function lst(jd, longitudeEast) {
return mod360(gmst(jd) + longitudeEast);
}
2451545.0 is the Julian Date of the J2000 epoch, noon on 1 January 2000, and the 360.98564736629 degrees per day is why the same star rises about four minutes earlier every night.
Step 3: the catalogue place becomes altitude and azimuth
The catalogue positions are for the year 2000. The Earth’s axis wobbles (precession), so for any other date the star’s right ascension and declination have shifted. chartFrame(jdUTC, latitude, longitudeEast) in js/astro.js is described in its own comment as “the one path every drawn object takes”: it builds the long-term precession matrix of Vondrák et al. (2011) once per chart (ltpMatrix, ported from ERFA), and returns an altAz(ra, dec) closure that rotates a J2000 place to the chart’s date and hands it to equatorialToHorizontal:
export function equatorialToHorizontal(raDeg, decDeg, latDeg, lstDeg) {
const H = (lstDeg - raDeg) * DEG; // hour angle, radians
const dec = decDeg * DEG;
const lat = latDeg * DEG;
const sinAlt =
Math.sin(dec) * Math.sin(lat) + Math.cos(dec) * Math.cos(lat) * Math.cos(H);
const alt = Math.asin(Math.max(-1, Math.min(1, sinAlt)));
That is the standard spherical-triangle conversion: the hour angle H is how far west of your meridian the star has turned, and latitude tilts the whole sky. Stars, constellation vertices, the Milky Way outline, the Sun, the Moon and the planets all go through the same frame, so they cannot drift apart from each other. The Moon gets one extra step: topocentricRaDec corrects for the observer standing on the Earth’s surface rather than at its centre, which matters for the Moon alone: it is close enough for that to move it by up to about two of its own widths, enough to decide whether it is above the horizon near moonrise.
Step 4: altitude and azimuth become a point on paper
project(altDeg, azDeg, { Rdisc, cx, cy, mode, eastLeft }) puts the zenith at the centre of the disc and the horizon on its rim. The default is the stereographic law, r = Rdisc · tan(z/2) with z the zenith distance, which keeps the shapes of constellations true near the horizon at the cost of stretching them; the other option, "azimuthal", spaces altitude evenly instead. North is at the top and east is on the left, because a star chart is held over your head, not laid on the ground.
makeSky in js/astro.js glues the four steps into one projectStar(ra, dec) closure, and that closure is all buildStarMapSVG in js/render.js calls for every object it places.
There is a second view of the same sky. buildHorizonSVG draws a panorama of half the sky as you would see it facing one compass direction, using horizonProject, a cylindrical projection: x is the azimuth offset from the direction you face, y is the altitude.
A worked example: Betelgeuse over London
Here is one star through all four steps, using the app’s own London coordinates (latitude 51.51, longitude −0.13) at 21:00 UTC on 15 January 2024:
$ node --input-type=module -e 'import * as A from "./js/astro.js"; import fs from "fs"; const jd=A.localToJulianUTC(2024,1,15,21,0,0,0); const s=JSON.parse(fs.readFileSync("data/bright-stars.json","utf8")).stars.find(x=>x.name==="Betelgeuse"); const f=A.chartFrame(jd,51.51,-0.13); const d=f.ofDate(s.ra,s.dec); const h=f.altAz(s.ra,s.dec); const p=A.project(h.alt,h.az); console.log([jd, A.gmst(jd).toFixed(2), f.lst.toFixed(2), s.ra, s.dec, d.ra.toFixed(2), d.dec.toFixed(2), h.alt.toFixed(2), h.az.toFixed(2), p.r.toFixed(3), p.x.toFixed(3), p.y.toFixed(3)].join(" "))'
2460325.375 69.81 69.68 88.7929 7.4069 89.12 7.41 43.07 153.15 0.434 -0.196 0.387
Read left to right: the instant is Julian Date 2460325.375; Greenwich sidereal time is 69.81°, and London’s, a hair west of Greenwich, 69.68°. Betelgeuse’s catalogue right ascension of 88.7929° becomes 89.12° after 24 years of precession. Its hour angle is therefore negative, so it has not yet crossed the meridian, which is why it lands at azimuth 153.15°, south-south-east, 43.07° up. On a disc of radius 1 that is 0.434 from the centre, at x −0.196 (left, which is east) and y 0.387 (below the centre, which is south).
Constellation lines and names
Each stick figure is walked vertex by vertex through projectStar. A run of vertices is kept while they stay above one degree below the horizon (isVisible(p.alt, -1)) and is cut where they drop below it, so a half-risen Orion is drawn as the half you can see, not as lines dangling off the rim. Stars use a strict floor of zero degrees and the magnitude limit, and their dot radius comes from magnitudeRadius, brighter meaning bigger.
Names are placed in priority order through one shared collision list, so nothing overlaps:
- the Sun and Moon first, then anything else you switched on (a comet, a meteor shower’s radiant, a birthday star), then the planets;
- then the 15 brightest named stars above the horizon (
.slice(0, 15)inbuildStarMapSVG); - then deep-sky objects, and last of all, if you switch them on, constellation names, smaller than star names (0.021 of the disc radius against 0.026), so a constellation name always yields to a star name.
A constellation name is anchored on the centroid of the vertices that actually survived the horizon cut, not of the whole figure, so “Hydra” is not printed in empty paper on a night when most of Hydra is below the horizon. When the centroid is taken (Orion’s centroid sits close to Betelgeuse’s label), the name may slide straight up or down by at most two lines, and never beyond its own figure’s drawn extent. A name whose box would cross the edge of the disc is dropped rather than printed half-cut.
The exports: SVG, PNG, PDF, and the cut file
Everything starts as one SVG string from buildStarMapSVG. The SVG download is that string. (The page also offers a JPEG and a short recorded night-sky video; this section sticks to the print paths.)
The PNG is that SVG drawn onto a canvas at 300 dpi for the chosen size:
$ node --input-type=module -e 'import {printPixels,printPoints} from "./js/render.js"; console.log(printPixels("50x70cm",300), printPoints("50x70cm"))'
{ w: 5906, h: 8268 } { w: 1417.3228346456694, h: 1984.251968503937 }
A 50 × 70 cm poster is 5906 × 8268 pixels. iPhones refuse canvases that big, so when the one-canvas attempt fails, js/app.js redraws the chart in horizontal strips of at most 4,194,304 pixels each and encodes the PNG itself with the browser’s CompressionStream.
The PDF carries that same raster on one page whose size is stated in points (1417.32 × 1984.25 for 50 × 70 cm), so a print shop does not have to be told the intended size. It is written by pdf-lib, the one third-party library, which loadPdfLib() injects only on the first click of Download PDF:
$ wc -c < vendor/pdf-lib.min.js
525099
The cut file is a separate builder, buildCutSVG, for laser cutters and engravers: the same sky at a real diameter in millimetres, one colour, no background. The outer circle is a red hairline in a cut layer; stars, lines and the horizon ring are black in an engrave layer. The defaults sit in js/render.js:
$ grep -n -A6 'CUT_DIAMETERS_MM = ' js/render.js
7920:export const CUT_DIAMETERS_MM = [100, 150, 200, 300];
7921-export const CUT_DEFAULTS = Object.freeze({
7922- diameterMm: 200,
7923- minStarMm: 0.3,
7924- lineMm: 0.25,
7925- captions: false,
7926-});
Stars whose dot would come out smaller than the minimum are left out instead of being burned as specks.
The languages it ships
$ grep -n 'UI_LANGS = ' js/i18n.js
44:export const UI_LANGS = Object.freeze(["en", "de", "da"]);
$ grep -n 'POSTER_LANGS = ' js/render.js
1113:export const POSTER_LANGS = ["en", "da", "de", "fr", "es", "sv", "nb", "nl"];
$ ls landing/*.html | wc -l
49
$ for f in landing/*.html; do grep -oE '<html[^>]*lang="[a-z]+' "$f" | sed 's/.*lang="//'; done | sort | uniq -c
9 da
9 de
31 en
The app itself is in English, German and Danish (index.html, de/ and da/). The words the chart prints on the poster, such as its date line and credit, come in 8 languages. There are 49 landing pages, each built around one occasion or question, such as a star map for a chosen date or a star chart for a specific date and location: 31 in English, 9 in German and 9 in Danish.
What runs in the browser, and what never leaves it
Nothing about the chart is sent anywhere. The date, time, place, title and any photo you add are read by JavaScript in the page and turned into SVG in the page. Apart from the site’s page-view counter, the requests the page makes are for its own static files: the scripts, the catalogue and figure files, the town list, fonts, and pdf-lib when you ask for a PDF. The photo option re-encodes your picture in the page, which also drops its EXIF block (a phone photo’s GPS position usually lives there), and keeps it in memory only.
Two honest qualifications. The chart’s settings are written into the address after the # so the link can be shared; browsers do not send that part to the server, but anyone you send the link to can read it. And on polyatic.com, the site’s page-view counter records that a download happened as one fixed event name such as starmap-download-png; countDownload in js/app.js only accepts names from a frozen list, so no date, place or title can ride along.
How it is tested
The astronomy is pure functions with no DOM, so the same file runs in Node:
$ node test/astro.test.js | tail -2
PASSED 44 FAILED 0
All astronomy checks passed.
$ ls test/*.test.js | wc -l
112
Those 44 checks are textbook values: the Julian Date of J2000, GMST at that epoch, Polaris’s altitude equalling the observer’s latitude, the projection’s orientation and the magnitude scaling. test/historic-sky.test.js goes further: it recomputes positions with its own independent code (its own Julian Day, its own sidereal time, its own precession matrix from ERFA’s published coefficients), then drives the real page in headless Chrome and reads the drawn positions back out of the SVG.
What it cannot do
- Refraction is not modelled. It does not model atmospheric refraction. Near the horizon the real atmosphere lifts every star a little; the chart draws where the star would be with no air.
- It stops at magnitude 5.0. A truly dark site shows several times more stars than the 1,630 it ships.
- The PDF is not vector. The PDF is not vector. It carries the same 300 dpi raster as the PNG; if a print shop wants vector artwork, give it the SVG.
- Typed coordinates do not get a time zone. Only a picked town carries one; for bare coordinates the offset is a guess from longitude that you may need to correct.
- Observer height is taken as sea level, and nutation (a small extra nod of the Earth’s axis) is left out of the star positions.
- It is a chart, not a planetarium. It cannot show anything its data files do not hold: no star fainter than the cut, no satellite, no aircraft.
- It does not print or ship. You get a file; printing and framing are up to you.
Dates are accepted from year 1 to year 3000. Before 15 October 1582 the date is read in the Julian calendar, the calendar people actually wrote dates in, and the star places are moved to that date’s equator and equinox, so a medieval chart is not drawn with modern star positions.