A natal chart here is a clock time and a place turned into angles. Ecliptic longitude of the Sun, Moon, and planets, and the longitude rising on the eastern horizon, are astronomy. The sentences written about character are entertainment. The terms in src/lib/legal.ts call the chart, the horoscope, and the tarot entertainment and reflection, and they say those pages are not medical, mental-health, legal, or financial advice.
The functions are in src/lib/astro.ts. package.json depends on astronomy-engine at ^2.1.19. signIndex wraps a longitude into [0, 360) and returns Math.floor(n / 30) % 12. SIGNS starts at Aries, so each sign is a 30° slice of ecliptic longitude. The chart page and the terms call that the tropical zodiac. The wheel counts whole-sign houses from the ascendant. Placidus cusps are a second list, empty when that system fails. The same file runs in the browser on the birth chart and on a Cloudflare Worker when a paid page needs the positions.
1. Turn the birth clock into UTC
The city sets the time zone. Copy in src/components/landings.tsx says the computer's clock does not. The form in src/components/birth-form.tsx submits only after a place is chosen ("Time zone comes from the city."). Ten CITIES rows carry latitude, longitude, and an IANA zone. A query of at least two characters also calls the Open-Meteo geocoding API, and a hit is kept only when it includes timezone.
Submit stores YYYY-MM-DD, 24-hour HH:MM, lat, lon, tz, and timeKnown. With the checkbox off, the clock is 12:00 and timeKnown is false. Hours are 1�12 plus AM/PM; (hour % 12) + (PM ? 12 : 0) turns 12 AM into 00 and 12 PM into 12.
computeChart calls zonedToUtc first. Incomplete numbers throw "Enter a complete birth date and time." That guard is left out of the snippet. The rest treats the civil fields as a UTC timestamp, formats that instant in the IANA zone, and adds the difference, at most four times.
export function zonedToUtc(date: string, time: string, timeZone: string): Date {
const [y, m, d] = date.split("-").map(Number);
const [hh, mm] = time.split(":").map(Number);
const target = Date.UTC(y, m - 1, d, hh, mm, 0);
let utc = target;
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
for (let i = 0; i < 4; i++) {
const parts = Object.fromEntries(
fmt.formatToParts(new Date(utc)).map((p) => [p.type, p.value]),
);
const asUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour) % 24,
Number(parts.minute),
);
const delta = target - asUtc;
if (delta === 0) break;
utc += delta;
}
return new Date(utc);
}
From src/lib/astro.ts. hourCycle: "h23" is set, and the parsed hour is still taken modulo 24. The chart caption credits the city clock, including daylight saving. An unknown time passes "12:00", so the longitudes are local noon.
2. Read ecliptic longitude
longitudeOf covers Sun through Pluto. The Sun uses SunPosition(date).elon. The Moon uses EclipticGeoMoon(date).lon. The other bodies go through GeoVector and then Ecliptic. The third argument is the boolean true. norm folds the result into [0, 360).
function longitudeOf(id: string, date: Date): number {
if (id === "sun") return norm(Astronomy.SunPosition(date).elon);
if (id === "moon") return norm(Astronomy.EclipticGeoMoon(date).lon);
const spec = BODIES.find((b) => b.id === id);
if (!spec?.body) throw new Error(`Unknown body ${id}`);
const ecl = Astronomy.Ecliptic(Astronomy.GeoVector(spec.body, date, true));
return norm(ecl.elon);
}
From src/lib/astro.ts. BODIES sets Astronomy.Body for Mercury through Pluto. The sign is SIGNS[signIndex(lon)], and the degree inside the sign is the wrapped longitude modulo 30.
North node, Lilith, and Chiron come from extraPoints. Node and Lilith are polynomials in Julian centuries from julianCenturies. Chiron is a Kepler solve from perihelion Date.UTC(1996, 1, 14, 18, 6). The comment on chironLon says inclination is ignored, so the longitude is within a few degrees. Those three receive a house when timeKnown is true, and 0 otherwise.
3. Sidereal time, obliquity, and the ascendant
chartAt reads obliquity with Astronomy.e_tilt(Astronomy.MakeTime(when)).tobl and passes it to the ascendant and the midheaven. ramcDegrees starts from Astronomy.SiderealTime. Western longitudes in the city table are negative (New York is -74.006). Dividing by 15 converts degrees to hours.
function ramcDegrees(date: Date, lon: number): number {
const gst = Astronomy.SiderealTime(date);
const lst = gst + lon / 15;
return norm(((lst % 24) + 24) % 24 * 15);
}
function ascendant(ramcDeg: number, latDeg: number, epsDeg: number): number {
const ramc = (ramcDeg * Math.PI) / 180;
const lat = (latDeg * Math.PI) / 180;
const eps = (epsDeg * Math.PI) / 180;
const y = Math.cos(ramc);
const x = -(Math.sin(ramc) * Math.cos(eps) + Math.tan(lat) * Math.sin(eps));
return norm((Math.atan2(y, x) * 180) / Math.PI);
}
From src/lib/astro.ts. midheaven is atan2(sin(RAMC), cos(RAMC) * cos(eps)), in degrees, then norm. Both angles are stored on the chart and as points asc and mc. The ascendant point is house 1. The midheaven uses houseOf.
4. Whole-sign houses
Whole-sign houses give the ascendant's sign to house 1 and each later sign to the next house.
export function houseOf(lon: number, asc: number): number {
return ((signIndex(lon) - signIndex(asc) + 12) % 12) + 1;
}
From src/lib/astro.ts. chartAt sets every planet house with that call, so 1° and 29° of the rising sign share a house. The wheel draws twelve ticks at i * 30 and places them with polar: longitude equal to the ascendant has relative angle 0, SVG angle 180°, and sits on the left. The center label is whole sign.
If the time is unknown, signsThatDay samples the body at local 00:00 and 23:59. A sign change returns both names. The Moon cell joins them with " or " and prints an em dash for the degree.
5. Placidus, the 66° cutoff, and the fallback
Placidus cusps are unequal. placidusCusps returns twelve longitudes, index 0 for house 1, or null. The comment says null is for "above 66° where the system fails."
export function placidusCusps(date: Date, lat: number, lon: number): number[] | null {
if (Math.abs(lat) > 66) return null;
const tilt = Astronomy.e_tilt(Astronomy.MakeTime(date)).tobl;
const ramc = ramcDegrees(date, lon);
const find = (fraction: number) => {
let best = 0;
let err = 999;
for (let trial = 0; trial < 3600; trial += 1) {
const lambda = trial / 10;
const eq = equatorial(lambda, tilt);
const arc = diurnalArc(lat, eq.dec);
if (arc == null) continue;
const target = norm(ramc + fraction * arc);
const gap = Math.abs(((eq.ra - target + 540) % 360) - 180);
if (gap < err) {
err = gap;
best = lambda;
}
}
return best;
};
Trimmed from src/lib/astro.ts. Longitude steps by 0.1° (trial / 10, 3600 trials). equatorial turns a trial longitude into right ascension and declination. diurnalArc returns null when -tan(lat) * tan(dec) falls outside (-1, 1), and that trial is skipped. The winner is the smallest 0�180° gap between right ascension and RAMC + fraction * arc. best starts at 0, so a fraction whose trials are all skipped yields longitude 0.
The function then calls find(1/3), find(2/3), find(-1/3), and find(-2/3) for the eleventh, twelfth, ninth, and eighth cusps. The returned order, each value through norm, is ascendant, eighth+180, ninth+180, mc+180, eleventh+180, twelfth+180, asc+180, eighth, ninth, midheaven, eleventh, twelfth. Houses 1, 4, 7, and 10 are the angles. Math.abs(lat) > 66 means latitude 66 still searches and 66.1 returns null at once. placidusFor in src/components/added.tsx returns null for an unknown time before any search.
6. Browser and Worker
src/routes/_app/chart.tsx renders ChartView. Until a birth is submitted, the view calls computeChart(SAMPLE_BIRTH): Avery Chen, 1994-04-12 at 07:42 in Singapore (1.3521, 103.8198, Asia/Singapore, timeKnown: true). Submit replaces that chart in client state. The caption says "Positions from astronomy-engine. Tropical zodiac, whole-sign houses."
Paid text uses the same module on the server. paidBlocks in src/lib/paid.ts is a createServerFn that loads src/lib/paid.server.ts. blocksFor starts with computeChart(birth) and also calls progressedChart, solarReturn, and lunarReturn. src/lib/western.ts calls computeChart for the solar arc and the Saturn return.
wrangler.jsonc points main at ./src/worker.ts, names the worker astralhoro, sets nodejs_compat, and binds D1 as DB. Better Auth sessions live in that database (src/lib/auth/server.ts), and the auth client is built inside the request. Every fetch copies string env vars onto process.env. The www host and plain HTTP redirect to the apex HTTPS host. Requests that reach the app assign env.DB to globalThis.DB and call the TanStack Start entry.
npm run build:cf sets CLOUDFLARE_ENV=production before Vite. vite.config.ts loads @cloudflare/vite-plugin only then (environment name ssr). Without the variable the build uses Nitro's vercel preset.
AstralHoro serves those routes. Placement paragraphs are the entertainment the terms describe.
Gotchas
-
zonedToUtcstops after four corrections. A civil time in a daylight-saving gap can return an instant whose zone clock still differs from the typed numbers. - The wheel draws
natalAspectsat orb 4°. The prose list keeps orbs through 6° and omits angles to the ascendant or midheaven when the time is unknown. -
birthOfinpaid.server.tsrejects latitude outside[-90, 90]or longitude outside[-180, 180]before the server callscomputeChart. The browser passes the chosen city's coordinates.
Top comments (0)