Sun Position
The astronomy behind the app's light direction, shadow calculator, and the standalone sun estimator
The app's Phong lighting mode can be driven by real solar position instead of a manually-dragged light control, and the sidebar's shadow-length calculator inverts the same astronomy the other direction (given a shadow's length and bearing, find when it was cast). Both are powered by lib/solar-position.ts. A more complete, interactive version of this same math — with day/night boundary, analemma, sun-path, and full-year-coverage charts — is available as a standalone page at terrain-viewer.iconem.com/sun-position-estimator.html.
There are four distinct ways to work with light/shadow direction in the app:
- Free — drag the XY pad directly to any azimuth/elevation, no astronomy involved at all.
- Datetime, forward — pick a day of year and time of day, and the app converts that into a real sun azimuth/elevation for your current latitude (
solarPosition()). - Shadow Calculator, Forward — set a datetime and an object's height, then click a single point on the map: the tool projects that object's cast shadow from the real sun position at that datetime, drawn on the map in your chosen color, base to tip.
- Shadow Calculator, Reverse — instead of a date, give an object's real height and click the base and tip of its cast shadow on the imagery; the shadow's length and orientation are converted back into a light direction, and that direction is inverted a second time into an approximate date/time (
inverseSunPosition()), demonstrated below.
The most telling demonstration is the Sun Shadow Calculator's Reverse mode: click a real shadow's base and tip in satellite imagery, give the object's real height, and the tool back-solves the light direction into a date/time. Here, Tour Montparnasse (Paris, 210m tall) casts a 222m shadow — solving for a light azimuth/elevation that resolves to March 30, 13:00 (there are generally two candidate days per year for a given sun position, mirrored around a solstice; the tool picks whichever is nearer an already-set reference date):

Sun Shadow Calculator, Reverse mode — Tour Montparnasse, Paris — base/tip picked on real imagery, 210m object height, resolves to 2026-03-30 13:00 — open in app ↗ (camera/light state only — the shadow base/tip pick and Reverse-mode solve itself aren't URL-reproducible, pick them on the map yourself)

Hillshade light-direction control, Datetime mode — top-down view, Monument Valley, USA — open in app ↗
Deliberately simple, not an ephemeris
Both the app's solar-position.ts and the standalone estimator use the same closed-form spherical-astronomy approximations (Cooper's declination, standard elevation/azimuth identities) rather than a precise NOAA-style algorithm. solar-position.ts additionally works entirely in local solar time (solar noon = 12:00) and skips longitude/timezone and the equation-of-time correction — accurate enough to place a light and show a believable day-length range on a slider, not to replace an ephemeris.
The formulas
Declination (Cooper's approximation; = day of year, = latitude):
Elevation and azimuth (forward — given latitude, declination, and hour angle ; morning, afternoon):
Inverse (given azimuth/elevation, recover declination and hour angle — used by the shadow calculator):
Since declination traces a sine curve over the year (not one-to-one), recovering a day-of-year from generically has two solutions per year — mirror images around a solstice:
whichever is closer to a caller-supplied reference day is picked. Exactly one solution exists at a solstice; zero exist if the implied (astronomically impossible).
Day length / sunrise-sunset hour angle:
Full-year sun-position coverage — the exact spherical-astronomy identity behind "what patch of sky does the sun ever occupy at this latitude":
Near the term vanishes and this reduces to , satisfiable only where — i.e. only within the tropics does the sun ever pass directly overhead.
Shadow length, the relation the in-app shadow calculator solves both directions:
Shadow azimuth is the sun's own azimuth plus (a shadow points away from the sun); feeding that pair into the inverse solver above recovers the date/time the shadow was cast.
What's not modeled
No atmospheric refraction is applied anywhere in solar-position.ts (the standalone estimator optionally applies a −0.833° refraction + solar-disk-radius correction to sunrise/sunset, this app's dayLength() does not). Neither implementation includes the equation-of-time or longitude correction needed to convert local solar time to civil clock time — treat "solar noon" as the reference, not a specific clock time at your location.
Charts on the standalone estimator
The full interactive version renders four charts per saved location, each answering a different question:
- Sunrise/sunset vs. day of year — a line chart (x = day of year, y = UTC hour) with a solid sunrise curve and dashed sunset curve. How does day length and sun timing shift across the year here?
- Analemma — azimuth (x) vs. elevation (y), tracing the sun's position at one fixed clock time across all 365 days — the classic figure-eight. Where does the sun appear at the same clock time every day, and why does it trace a figure eight rather than a point?
- Sun-path diagram — the same yearly trace in a polar compass projection (angle = azimuth, N at top, clockwise; radius = elevation, center = zenith). Where in the sky, compass-relative, does the sun sit at a given hour across the year?
- Full-year coverage envelope — a filled region in the same polar projection, computed from the reachability inequality above, showing the entire dome of sky the sun occupies at any hour on any day of the year at this latitude.
Where this shows up in the app
components/TerrainControlPanel/light-direction-control.tsx— the datetime-driven / compass-fixed light-direction control for Phong, usingsolarPosition(),inverseSunPosition(), anddayLength()to bound the day-length slider.components/TerrainControlPanel/sun-shadow-calculator-section.tsx— the shadow-length solver, usinginverseSunPosition()in both directions as described above.components/TerrainControlPanel/XYPad.tsx— draws the reachable-sky-dome backdrop on the light-direction XY pad (viayearlySunEnvelope()/isSunPositionReachable()) and flags an azimuth/elevation pick that the sun could never actually reach at the current latitude.