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, 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 ↗
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.

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 ↗

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 ↗

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.



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; = 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.
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:
- 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.
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, 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.