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 calculator sits in the sidebar's Tools group. Whichever way it runs, the answer is written into the same shared light fields the Hillshade control uses (illuminationDir, illuminationAlt, lightDayOfYear, lightTimeOfDay), so Hillshade, Phong and cast Shadows all snap to the solved light at once: it is another way of setting the one light, not a separate lighting model.

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 ↗

Building shadows

Lighting Effects → Shadows → Buildings (OSM) draws the shadow of every OpenStreetMap building in view, with heights from OpenFreeMap's building layer, from the same light as the rest of the app. In Datetime mode that light is the sun at the date and time on the sliders, so a date and an hour are enough to see which streets and squares are in the shade. The shadows are swept on flat ground at the buildings' feet (each footprint pushed away from the sun by height / tan(altitude)); how the layer is drawn is in Visualization Modes. In 3D the shadows drape over the terrain: with terrain on, the layer draws each terrain tile through MapLibre 6.13's experimental renderToTerrainTile hook and MapLibre lays the result on the relief, so a shadow follows a hill as the streets do (its length is still the flat-ground sweep, not shortened or stretched by the slope). The views below look straight down (pitch 0, north up, zoom 17.25). The grey blocks are the OpenStreetMap basemap's own extruded buildings, seen from above: their faces keep the style's fixed light and do not follow the sun, only the dark ground shadows do.

The three views are Bryant Park, Manhattan, on June 21 (lightDayOfYear=172), at New York wall-clock time (lightTimeMode defaults to local, the civil time at the map centre, daylight saving included). Each link opens the same top-down view; the light direction is computed from the date and time on load.

Building shadows at 9:00 on June 21 over Bryant Park, Manhattan, seen from straight above: the sun in the east at 38°, the shadows of the Fifth Avenue buildings fall west across the park

9:00, top-down view (pitch 0, zoom 17.25), sun at azimuth 90.5°, altitude 38.2°: shadows about 1.3 times the buildings' height, cast due west, so the tower on the park's east side shades the lawn · open in app ↗

Building shadows at 13:00 on June 21 over Bryant Park, seen from straight above: the sun near the meridian at 73°, short shadows pointing north, the park in full sun

13:00, top-down view (pitch 0, zoom 17.25), sun at azimuth 183.1°, altitude 72.7°: close to solar noon in daylight saving time, the shadows shrink to about a third of the buildings' height and point north, and the park is in full sun · open in app ↗

Building shadows at 17:00 on June 21 over Bryant Park, seen from straight above, with the Lighting Effects panel open: Buildings (OSM) checked, Light Direction in Datetime mode on Jun 21 at 17:00 local, sunrise 05:28 and sunset 20:24 marked on the time slider

17:00, top-down view (pitch 0, zoom 17.25), sun at azimuth 270.8°, altitude 36.6°: the shadows swing to the east and the buildings west of the park (Sixth Avenue) now shade it; the panel shows the date and time sliders that drive the sun, with the day's sunrise and sunset under the time slider. Dragging the time slider repaints the shadows live · open in app ↗ (open the sidebar's Lighting Effects section and its Light Direction fold to see the panel)

Flat footprints

The same three hours with the buildings drawn flat, as 2D footprints with no extrusion. With flat footprints the whole shape of each shadow reads, from the building's foot to its tip. With the 3D blocks, even from straight above, each block is drawn in perspective and its top leans away from the centre of the view, covering part of its own shadow and of its neighbours'. The shadows themselves are the same in both sets; only the buildings drawn over them change.

Building shadows at 9:00 on June 21 over Bryant Park with the buildings drawn as flat footprints: the full length of the shadows cast west across the park and the streets

Building shadows at 13:00 on June 21 over Bryant Park with flat footprints: short shadows on the north side of every block

Building shadows at 17:00 on June 21 over Bryant Park with flat footprints: the shadows of the Sixth Avenue buildings fall east across the park

9:00, 13:00 and 17:00, same views and links as above. How these were made: the OSM basemap's 3D switch (sidebar → Basemap, next to OSM) turned off, which hides the extruded blocks; the OpenFreeMap Liberty style draws its flat footprints only up to zoom 14, so with the switch off the app extends them to every zoom and the buildings stay drawn flat.

Building shadows need zoom 13 or closer and the Mercator projection. For shadows cast by the real shape of a building (a spire, a dome) and falling on slopes, use the Terrain shadows with a surface model (DSM) as the terrain source.

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⁡δcos⁡H)\text{El} = \arcsin\big(\sin\varphi\sin\delta + \cos\varphi\cos\delta\cos H\big) Az=atan2⁡(sin⁡H, cos⁡Hsin⁡φ−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=12−H015°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 El→90°\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 altitude→shadow 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 shadow→sun 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.

The standalone sun position page

terrain-viewer.iconem.com/sun-position-estimator.html is a single HTML page, apart from the map app: no map, no terrain, no building data. It runs the same closed-form astronomy for a list of locations and draws them on the same charts so they can be compared. It starts with Paris, Bogotá and Sydney; you add a place by typing a name and its latitude and longitude (it does not look up a place name, and the list is not saved when the page is closed), and a checkbox shows or hides each location.

What it computes, beyond the app's sliders:

  • The sun's azimuth and elevation from latitude, longitude and UTC time, with the equation of time (Spencer's approximation), so times are UTC rather than local solar time.
  • Sunrise and sunset in UTC for every day of the year, with the −0.833° refraction and solar-disk correction.
  • The analemma at a chosen UTC time (an "Isoline time" slider, noon by default), as azimuth/elevation and on a compass sun-path diagram.
  • The full-year envelope of sky positions the sun can reach at each latitude.
  • A shadow-length solver: an object's height, its shadow's length and the bearing it points to (typed or clicked on a dial) give the sun's direction, back-solved into a day of year and a UTC time, the nearer of the two candidate days to a reference day.

How it relates to the in-app tool: the app's Datetime light and Sun Shadow Calculator use lib/solar-position.ts, which shares the declination and inverse formulas but takes the map centre as the location and converts the time slider (local civil time or UTC) to solar time from the longitude; the app then lights the terrain and casts terrain and building shadows with the result. The standalone page has no map, so it is the place to look at a whole year at once, or to compare latitudes side by side, rather than at one moment over one place.

Its charts

The page renders four charts, every visible location overlaid in its own colour, 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.

Monthly irradiation, elsewhere

The tool answers "where is the sun, and what does it shade, at this moment". For energy over time, the monthly sum of global irradiation (beam, diffuse and reflected) and hours of sunlight per pixel with terrain shading, GRASS's r.sun is the reference; OpenTopography runs it as its Global Solar Irradiation service on its global rasters, and QGIS exposes it through the GRASS provider for any DEM. See Compared with other tools.

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