turn timestamp / MILLISECONDS_PER_DAY + UNIX_EPOCH_JD;
}
The `.5` offset exists because Julian days begin at noon, not midnight. This convention ensures that a single night of observation never straddles two calendar days. Midnight UTC sits exactly 0.5 days after the preceding JD noon.
### Step 2: Phase Derivation
The synodic month—the average interval between successive new moons—measures 29.530588853 days. By anchoring to a known new moon epoch (JD 2451550.1, corresponding to 2000-01-06 14:24 UTC), we compute the fractional progress through the current cycle.
```typescript
const SYNODIC_CYCLE = 29.530588853;
const ANCHOR_NEW_MOON_JD = 2451550.1;
export interface LunarState {
fraction: number;
ageDays: number;
illumination: number;
isWaxing: boolean;
}
export function computeLunarCycle(timestamp: number): LunarState {
const currentJD = toJulianEpoch(timestamp);
const elapsedDays = currentJD - ANCHOR_NEW_MOON_JD;
let fraction = (elapsedDays / SYNODIC_CYCLE) % 1;
if (fraction < 0) fraction += 1;
const ageDays = fraction * SYNODIC_CYCLE;
const illumination = (1 - Math.cos(2 * Math.PI * fraction)) / 2;
const isWaxing = fraction < 0.5;
return { fraction, ageDays, illumination, isWaxing };
}
The fractional output maps directly to visual stages: 0 (new), 0.25 (first quarter), 0.5 (full), 0.75 (last quarter). Illumination follows a cosine curve, peaking at full moon and bottoming at new moon. The waxing flag resolves the symmetry ambiguity between first and last quarters.
Step 3: SVG Terminator Synthesis
The boundary between illuminated and shadowed lunar surface (the terminator) projects as an ellipse onto a 2D plane. The visible lit area decomposes into two SVG arc commands: a semicircular outer edge and a half-elliptical inner curve.
export function generateTerminatorPath(fraction: number, radius: number): string {
if (fraction < 0.001 || fraction > 0.999) return "";
if (Math.abs(fraction - 0.5) < 0.001) {
return `M ${-radius} 0 A ${radius} ${radius} 0 1 1 ${radius} 0 A ${radius} ${radius} 0 1 1 ${-radius} 0 Z`;
}
const cosineComponent = Math.cos(2 * Math.PI * fraction);
const minorAxis = Math.abs(cosineComponent) * radius;
const isWaxing = fraction < 0.5;
const outerSweep = isWaxing ? 1 : 0;
const innerSweep = cosineComponent < 0 ? 1 : 0;
return [
`M 0 ${-radius}`,
`A ${radius} ${radius} 0 0 ${outerSweep} 0 ${radius}`,
`A ${minorAxis} ${radius} 0 0 ${innerSweep} 0 ${-radius}`,
"Z"
].join(" ");
}
The outer arc traces the lunar limb. The inner arc follows the terminator. Sweep flags dictate clockwise (1) or counter-clockwise (0) tracing. When cosineComponent is negative (gibbous phases), the ellipse bulges into the dark hemisphere. When positive (crescent phases), it carves into the lit hemisphere.
Predicting the next full or new moon avoids iterative search by inverting the phase equation. Since phase progresses linearly with Julian Date, we solve directly for the target epoch.
export function predictNextEvent(timestamp: number, targetFraction: number): number {
const startJD = toJulianEpoch(timestamp);
const cycleOffset = (startJD - ANCHOR_NEW_MOON_JD) / SYNODIC_CYCLE;
const k = Math.ceil(cycleOffset - targetFraction);
const targetJD = ANCHOR_NEW_MOON_JD + (targetFraction + k) * SYNODIC_CYCLE;
return (targetJD - UNIX_EPOCH_JD) * MILLISECONDS_PER_DAY;
}
targetFraction accepts 0 for new moon or 0.5 for full moon. The Math.ceil operation guarantees the next occurrence strictly after the input timestamp.
Architecture Rationale
The design enforces strict separation between computation and rendering. The math module contains zero DOM references, enabling synchronous unit testing without browser mocks. The rendering layer consumes pure outputs and injects them into SVG elements. This pattern eliminates side effects, simplifies caching, and allows the same logic to run in Node.js, Web Workers, or edge runtimes.
Pitfall Guide
1. JavaScript's Truncated Modulo Operator
Explanation: The % operator in JavaScript returns the remainder of truncated division. Negative dividends yield negative results, breaking phase normalization.
Fix: Always normalize with if (fraction < 0) fraction += 1; after modulo operations.
2. Julian Date Noon Offset Misconception
Explanation: Developers frequently assume JD starts at midnight, introducing a 12-hour drift in all calculations.
Fix: Anchor conversions to 2440587.5 (midnight UTC = JD noon + 0.5 days). Document the offset explicitly in constants.
3. SVG Sweep Flag Confusion
Explanation: The sweep flag (0 or 1) controls arc direction, not visual orientation. Misinterpreting it produces mirrored or inverted terminator shapes.
Fix: Memorize the mapping: 1 = clockwise, 0 = counter-clockwise. Derive flags from waxing state and cosine sign, not hardcoded values.
4. Assuming Constant Synodic Period
Explanation: The 29.530588853-day value is a long-term average. Actual intervals vary by ±7 hours due to orbital eccentricity and gravitational perturbations.
Fix: Treat outputs as mean-phase approximations. Add ±12 hour tolerance windows for event scheduling. Use ELP-2000/82 or JPL Horizons only for sub-hour precision requirements.
5. Phase Name Binning Without Offset
Explanation: Direct floor division on phase fractions places canonical events (new, quarter, full) exactly on bin boundaries, causing flickering or misclassification during transitions.
Fix: Apply a 1/16 offset before binning: Math.floor((fraction + 1/16) * 8) % 8. This centers canonical phases within their respective bins.
6. Ignoring Illumination Symmetry
Explanation: Illumination alone cannot distinguish first quarter from last quarter. Both yield 0.5 lit fraction.
Fix: Always pair illumination with a directional flag (fraction < 0.5 for waxing, >= 0.5 for waning). Never derive phase name from illumination alone.
7. Over-Engineering SVG with Canvas
Explanation: Developers often reach for <canvas> for dynamic shapes, introducing rasterization overhead and losing accessibility/scalability.
Fix: Use SVG path strings for geometric primitives. They scale infinitely, remain accessible to screen readers, and require zero animation loops for static phases.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Client-side UI indicator | Deterministic math + SVG | Zero network calls, instant render, offline-ready | Eliminates API costs, reduces bundle by 80KB+ |
| Calendar scheduling with ±12h tolerance | Deterministic math + closed-form prediction | Sufficient accuracy for planning, fully local | No infrastructure overhead |
| Scientific observation planning | JPL Horizons / ELP-2000 | Sub-minute precision required for telescope pointing | High compute cost, requires external data feeds |
| Mobile app with limited storage | Deterministic math | <5KB footprint, no runtime dependencies | Reduces app size, improves install conversion |
Configuration Template
// lunar-config.ts
export const LUNAR_CONSTANTS = {
UNIX_EPOCH_JD: 2440587.5,
MS_PER_DAY: 86400000,
SYNODIC_MONTH: 29.530588853,
REFERENCE_NEW_MOON_JD: 2451550.1,
PHASE_BIN_OFFSET: 1 / 16,
ACCURACY_TOLERANCE_HOURS: 12,
} as const;
export const PHASE_LABELS = [
"New Moon",
"Waxing Crescent",
"First Quarter",
"Waxing Gibbous",
"Full Moon",
"Waning Gibbous",
"Last Quarter",
"Waning Crescent",
] as const;
export interface LunarEngineConfig {
radius: number;
precision: number;
timezoneOffsetMinutes: number;
}
export const DEFAULT_CONFIG: LunarEngineConfig = {
radius: 100,
precision: 3,
timezoneOffsetMinutes: 0,
};
Quick Start Guide
- Initialize the module: Copy the constants and pure functions into a single TypeScript file. Ensure no DOM references exist in the computation layer.
- Compute current state: Pass
Date.now() to computeLunarCycle(). Extract fraction, illumination, and isWaxing.
- Generate SVG path: Feed the fraction and desired radius into
generateTerminatorPath(). Inject the returned string into an <path d="..."> element.
- Schedule next event: Call
predictNextEvent(Date.now(), 0.5) for the upcoming full moon. Convert the returned timestamp to a local date string for display.
- Validate locally: Run unit tests covering negative dates, boundary fractions (
0.001, 0.499, 0.501, 0.999), and sweep flag combinations. Verify outputs match known lunar calendars within the ±12 hour tolerance window.