DEV Community

Akhmad Erlangga
Akhmad Erlangga

Posted on

How We Built an Astronomical Chinese Calendar Engine in TypeScript

Every software engineer eventually encounters the horror of calendar systems: leap years, daylight saving anomalies, and historical Gregorian skips.

When building SPYLL, an interactive engine that computes traditional Chinese metaphysics charts (BaZi and Zi Wei Dou Shu) alongside modern psychological archetypes, we had to model a calendar system that is over two thousand years old.

Most existing BaZi calculators on the web were written in the late 1990s or early 2000s. They often rely on hardcoded static lookup tables, ignore longitude-based solar time offsets, and frequently break across time zones.

We wanted to build a modern, deterministic engine in TypeScript that runs client-side with zero API latency and zero external token cost. Here is how we tackled the core architectural and astronomical problems.


The Core Misconception: When Does a Year Actually Start?

The most common mistake when building a Chinese calendar engine is assuming the astrological year starts on Chinese New Year (the Spring Festival).

It does not.

Chinese lunar-solar metaphysics operates on the sexagenary cycle (Gan-Zhi / 60 combinations of Heavenly Stems and Earthly Branches). The Year Pillar does not transition on the first day of the first lunar month. Instead, it transitions at the exact astronomical moment of Lichun (Start of Spring), defined as the Sun reaching exactly 315 degrees of apparent tropical celestial longitude.

This means a person born three days after Lunar New Year in a given year might still belong to the previous astrological year if Lichun has not occurred yet.

To handle this reliably, we rely on the 24 solar terms (Jieqi) computed through astronomical coordinate transitions rather than static Gregorian calendar approximations:

export type WuXingElementKey = "Wood" | "Fire" | "Earth" | "Metal" | "Water";

export interface PillarDetail {
  stem: string;
  branch: string;
  stemPinyin: string;
  branchPinyin: string;
  stemElementEn: string;
  branchZodiacEn: string;
  combined: string;
  isCalculated: boolean;
}

export interface BaziPillars {
  year: PillarDetail;
  month: PillarDetail;
  day: PillarDetail;
  hour: PillarDetail;
}
Enter fullscreen mode Exit fullscreen mode

Each pillar represents a coordinate pair: a Heavenly Stem (one of ten polar element states) and an Earthly Branch (one of twelve zodiac animals and seasonal stages).


Problem 1: True Solar Time vs. Clock Time

Your wall clock is an artificial political construct. Standard time zones cover wide geographical bands, meaning noon on your watch (12:00 PM) rarely coincides with when the Sun is at its highest altitude (solar noon).

In BaZi, the Hour Pillar changes every two hours (the twelve double-hours, or Shichen, starting from the Rat hour at 23:00 to 01:00). If someone was born at 23:15 in a city on the western edge of their time zone, their True Solar Time might actually be 22:45. That thirty-minute discrepancy shifts both their Day Pillar and their Hour Pillar, completely altering their chart calculation.

To resolve this without forcing users to enter raw coordinates, we implement a two-step pipeline:

  1. Resolve the user's birthplace to latitude, longitude, and IANA time zone identifier.
  2. Calculate the local meridian offset and adjust for the Equation of Time (EOT).
function getTimezoneOffsetMs(date: Date, timezone: string): number {
  const parts = new Intl.DateTimeFormat("en-US", {
    timeZone: timezone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
    hourCycle: "h23",
  }).formatToParts(date);

  const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
  const asUtc = Date.UTC(
    Number(values.year),
    Number(values.month) - 1,
    Number(values.day),
    Number(values.hour),
    Number(values.minute),
    Number(values.second)
  );

  return asUtc - date.getTime();
}
Enter fullscreen mode Exit fullscreen mode

Using native Intl.DateTimeFormat.formatToParts() allows us to compute accurate historical offsets including historical DST transitions without shipping huge timezone database bundles to the client.


Problem 2: Ephemeris Calculations in Pure TypeScript

Beyond the traditional four pillars, our system compares elemental distributions against astronomical planetary positions (Sun, Moon, Ascendant, Mercury, Venus, Mars).

Historically, software doing this relied on native C libraries like the Swiss Ephemeris (swisseph). Compiling C binaries or running server-side WebAssembly adds cold-start latency and infrastructure overhead.

Instead, we ported the mathematical reductions of Jean Meeus's Astronomical Algorithms directly to TypeScript using the astronomia package, utilizing the VSOP87B heliocentric planetary data series.

Here is our Ascendant (Rising Sign) calculation, derived from Greenwich Sidereal Time, local longitude, and the true obliquity of the ecliptic:

function calculateAscendant(
  jd: number,
  latitude: number,
  longitude: number
): number {
  const RAD = Math.PI / 180;
  const DEG = 180 / Math.PI;

  const latitudeRad = latitude * RAD;
  const epsRad = meanObliquityRad(jd);
  const apparentSiderealSeconds = sidereal.apparent(jd);

  // Greenwich Sidereal Time to degrees
  const gstDeg = normalizeDegrees(apparentSiderealSeconds / 240);
  const localSiderealRad = normalizeDegrees(gstDeg + longitude) * RAD;

  // Ascendant formula (derived from spherical trigonometry):
  // Asc = atan2(cos(θ), -(sin(θ) * cos(ε) + tan(φ) * sin(ε)))
  const ascRad = Math.atan2(
    Math.cos(localSiderealRad),
    -(Math.sin(localSiderealRad) * Math.cos(epsRad) +
      Math.tan(latitudeRad) * Math.sin(epsRad))
  );

  return normalizeDegrees(ascRad * DEG);
}
Enter fullscreen mode Exit fullscreen mode

Because the VSOP87B reduction is purely trigonometric series evaluation, modern JavaScript engines optimize these loops aggressively. The entire calculation (Four Pillars + Zi Wei Dou Shu palaces + planetary positions) completes in under 4 milliseconds on standard mobile hardware.


Separating Math from Interpretation

One of the biggest architectural decisions was keeping the astronomical calculator completely decoupled from narrative generation.

The calculation module outputs deterministic JSON:

  • Four Pillars (Stems, Branches, Hidden Stems)
  • Daymaster element and polarity
  • Five-element percentage distribution (Wu Xing class)
  • Palace configurations and star distributions
{
  "daymaster": "Jia",
  "elementKey": "Wood",
  "pillars": {
    "year": "Geng-Chen",
    "month": "Ren-Wu",
    "day": "Jia-Chen",
    "hour": "Geng-Wu"
  },
  "distribution": {
    "Wood": 0.25,
    "Fire": 0.25,
    "Earth": 0.35,
    "Metal": 0.15,
    "Water": 0.00
  }
}
Enter fullscreen mode Exit fullscreen mode

By keeping the mathematical foundation strictly deterministic, we ensure that:

  1. Chart generation is instantaneous on the frontend.
  2. The data structure can be serialized, cached, and tested against historical edge cases.
  3. Downstream consumers (like our LLM synthesis layer, canvas share-card generator, and PDF blueprint compiler) receive reliable, strongly typed inputs without needing to know anything about astronomy.

Lessons Learned

  1. Never trust local midnight: Always normalize input timestamps to UTC before projecting onto local solar meridians.
  2. Calendar terms are continuous functions: Solar terms are points along a continuous orbit, not discrete day blocks. A cutoff can happen at 14:32:18 on a Tuesday.
  3. TypeScript types make ancient systems manageable: Modeling the 10 Heavenly Stems and 12 Earthly Branches as strict union types catches invalid combinations at compile time, eliminating hundreds of potential runtime edge cases.

If you are interested in seeing the engine in action or exploring how we translate raw planetary and BaZi charts into structured personality blueprints, check out the live implementation at SPYLL.

Top comments (0)