Terrain Viewer
Features

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's real shadow (base + tip picked on the imagery, 210m object height) resolves to a light direction, which back-solves to March 30, 13:00

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)

The Hillshade light-direction control in Datetime mode: date/time sliders bound to the XY pad

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; NN = day of year, φ\varphi = latitude):

δ=23.45°sin ⁣(360°365(284+N))\delta = 23.45° \cdot \sin\!\left(\frac{360°}{365}(284 + N)\right)

Elevation and azimuth (forward — given latitude, declination, and hour angle HH; H<0H < 0 morning, H>0H > 0 afternoon):

El=arcsin(sinφsinδ+cosφcosδcosH)\text{El} = \arcsin\big(\sin\varphi\sin\delta + \cos\varphi\cos\delta\cos H\big) Az=atan2(sinH, cosHsinφtanδcosφ)+180°\text{Az} = \operatorname{atan2}(\sin H,\ \cos H\sin\varphi - \tan\delta\cos\varphi) + 180°

Inverse (given azimuth/elevation, recover declination and hour angle — used by the shadow calculator):

δ=arcsin(sinφsin(El)+cosφcos(El)cos(Az))\delta = \arcsin\big(\sin\varphi\sin(\text{El}) + \cos\varphi\cos(\text{El})\cos(\text{Az})\big) H=atan2(sin(Az), cos(Az)sinφtan(El)cosφ)+180°H = \operatorname{atan2}\big(\sin(\text{Az}),\ \cos(\text{Az})\sin\varphi - \tan(\text{El})\cos\varphi\big) + 180°

Since declination traces a sine curve over the year (not one-to-one), recovering a day-of-year from δ\delta generically has two solutions per year — mirror images around a solstice:

N1=365360arcsin ⁣(δ23.45°)284(mod365)N_1 = \frac{365}{360}\arcsin\!\left(\frac{\delta}{23.45°}\right) - 284 \pmod{365} N2=365360(180°arcsin ⁣(δ23.45°))284(mod365)N_2 = \frac{365}{360}\left(180° - \arcsin\!\left(\frac{\delta}{23.45°}\right)\right) - 284 \pmod{365}

whichever is closer to a caller-supplied reference day is picked. Exactly one solution exists at a solstice; zero exist if the implied δ>23.45°|\delta| > 23.45° (astronomically impossible).

Day length / sunrise-sunset hour angle:

H0=arccos(tanφtanδ)sunrise=12H015°sunset=12+H015°(local solar time)H_0 = \arccos(-\tan\varphi\tan\delta) \qquad \text{sunrise} = 12 - \frac{H_0}{15°} \qquad \text{sunset} = 12 + \frac{H_0}{15°} \quad \text{(local solar time)}

Full-year sun-position coverage — the exact spherical-astronomy identity behind "what patch of sky does the sun ever occupy at this latitude":

reachable(Az,El)    sinφsin(El)+cosφcos(El)cos(Az)sin(23.45°)\text{reachable}(\text{Az}, \text{El}) \iff \big|\sin\varphi\sin(\text{El}) + \cos\varphi\cos(\text{El})\cos(\text{Az})\big| \le \sin(23.45°)

Near El90°\text{El} \to 90° the cos(El)\cos(\text{El}) term vanishes and this reduces to sinφsinδ\sin\varphi \approx \sin\delta, satisfiable only where φ23.45°|\varphi| \le 23.45° — 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:

shadowLength=heighttan(altitude)(forward: gnomon height + sun altitudeshadow length)\text{shadowLength} = \frac{\text{height}}{\tan(\text{altitude})} \quad \text{(forward: gnomon height + sun altitude} \to \text{shadow length)} altitude=atan2(height, shadowLength)(inverse: measured shadowsun altitude)\text{altitude} = \operatorname{atan2}(\text{height},\ \text{shadowLength}) \quad \text{(inverse: measured shadow} \to \text{sun altitude)}

Shadow azimuth is the sun's own azimuth plus 180°180° (a shadow points away from the sun); feeding that (Az,El)(\text{Az}, \text{El}) 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:

  1. 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?
  2. 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?
  3. 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?
  4. 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, using solarPosition(), inverseSunPosition(), and dayLength() to bound the day-length slider.
  • components/TerrainControlPanel/sun-shadow-calculator-section.tsx — the shadow-length solver, using inverseSunPosition() in both directions as described above.
  • components/TerrainControlPanel/XYPad.tsx — draws the reachable-sky-dome backdrop on the light-direction XY pad (via yearlySunEnvelope()/isSunPositionReachable()) and flags an azimuth/elevation pick that the sun could never actually reach at the current latitude.

On this page