Skip to content
Shadows from the Sun

Cast Shadows from a Sun Position in Maptoolkit Maps JS

A shadow from an arbitrary light makes a model look placed. A shadow from the actual position of the sun for a given date, time and latitude makes it information, and it is the basis of any shading study, solar assessment or “will this terrace get evening sun” question. The astronomy is about thirty lines and needs no library.

{
            "imports": {
                "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
                "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
            }
        }

import * as THREE from 'three';
    import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

    const API_KEY = 'YOUR_API_KEY';

    const ORIGIN = [11.39085, 47.27574];
    const DATE = new Date(Date.UTC(2026, 5, 21)); // June solstice, the extreme case

    // --- Solar position, after the standard low-precision NOAA formulas -------------
    const rad = Math.PI / 180;
    const J1970 = 2440588, J2000 = 2451545, dayMs = 86400000;
    const obliquity = rad * 23.4397;

    function sunPosition(date, lat, lng) {
        const days = date.valueOf() / dayMs - 0.5 + J1970 - J2000;

        const meanAnomaly = rad * (357.5291 + 0.98560028 * days);
        const centre = rad * (1.9148 * Math.sin(meanAnomaly) +
                              0.02 * Math.sin(2 * meanAnomaly) +
                              0.0003 * Math.sin(3 * meanAnomaly));
        const eclipticLongitude = meanAnomaly + centre + rad * 102.9372 + Math.PI;

        const declination = Math.asin(Math.sin(obliquity) * Math.sin(eclipticLongitude));
        const rightAscension = Math.atan2(Math.sin(eclipticLongitude) * Math.cos(obliquity),
                                          Math.cos(eclipticLongitude));

        const west = rad * -lng;
        const hourAngle = rad * (280.16 + 360.9856235 * days) - west - rightAscension;
        const phi = rad * lat;

        return {
            altitude: Math.asin(Math.sin(phi) * Math.sin(declination) +
                                Math.cos(phi) * Math.cos(declination) * Math.cos(hourAngle)),
            // Measured from south, turning west. Converted to compass bearing below.
            azimuth: Math.atan2(Math.sin(hourAngle),
                                Math.cos(hourAngle) * Math.sin(phi) - Math.tan(declination) * Math.cos(phi))
        };
    }
    // -------------------------------------------------------------------------------

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: ORIGIN,
        zoom: 17,
        pitch: 60,
        bearing: 20,
        antialias: true,
        attributionControl: { compact: false }
    });

    // terrainControl:false matters here. Left on, the control switches 3D terrain on the
    // first time the map is tilted, and this custom layer draws at a fixed altitude, so the
    // ground would rise through the scene.
    map.addControl(new maptoolkit.NavigationControl({ visualizePitch: true, terrainControl: false }), 'top-right');

    const originMercator = maptoolkit.MercatorCoordinate.fromLngLat(ORIGIN, 0);
    const scale = originMercator.meterInMercatorCoordinateUnits();

    let sunLight = null;

    function setTime(minutes) {
        const when = new Date(DATE.valueOf() + minutes * 60000);
        const { altitude, azimuth } = sunPosition(when, ORIGIN[1], ORIGIN[0]);

        const compass = (azimuth * 180 / Math.PI + 180) % 360;
        const elevation = altitude * 180 / Math.PI;

        document.getElementById('time').textContent =
            `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')} UTC`;
        document.getElementById('sun').textContent = elevation > 0
            ? `sun ${elevation.toFixed(0)}° above horizon, bearing ${compass.toFixed(0)}°`
            : `sun ${Math.abs(elevation).toFixed(0)}° below horizon`;

        // Darken the map as the sun sets. Civil twilight is about 6 degrees below the
        // horizon, so fade across that band rather than switching at zero.
        const darkness = Math.min(1, Math.max(0, (6 - elevation) / 18));
        document.getElementById('nightfall').style.opacity = (darkness * 0.62).toFixed(3);

        if (!sunLight) return;

        // Scene axes: X east, Z south, Y up. Distance is arbitrary for a directional light.
        const d = 400;
        sunLight.position.set(
            d * Math.cos(altitude) * Math.sin(azimuth),
            d * Math.sin(altitude),
            d * Math.cos(altitude) * Math.cos(azimuth)
        );
        // Below the horizon there is no direct light, so no shadow either.
        sunLight.intensity = Math.max(0, Math.sin(altitude)) * 3;
        map.triggerRepaint();
    }

    const customLayer = {
        id: 'sun-shadow',
        type: 'custom',
        renderingMode: '3d',

        onAdd(map, gl) {
            this.map = map;
            this.camera = new THREE.Camera();
            this.scene = new THREE.Scene();

            this.scene.add(new THREE.AmbientLight(0xbfd4ff, 0.9));

            sunLight = new THREE.DirectionalLight(0xfff3e0, 3);
            sunLight.castShadow = true;
            // Tight bounds around the model, not the scene: this is the sharpness dial.
            const c = sunLight.shadow.camera;
            c.left = c.bottom = -60; c.right = c.top = 60;
            c.near = 1; c.far = 900;
            sunLight.shadow.mapSize.set(2048, 2048);
            sunLight.shadow.bias = -0.003;
            this.scene.add(sunLight);
            this.scene.add(sunLight.target);

            // An invisible plane that receives the shadow and nothing else.
            const ground = new THREE.Mesh(
                new THREE.PlaneGeometry(600, 600),
                new THREE.ShadowMaterial({ opacity: 0.4 })
            );
            ground.rotateX(-Math.PI / 2);
            ground.receiveShadow = true;
            // MapLibre supplies the projection matrix, so three.js cannot derive a correct
            // frustum from it and culls at some tilt and bearing angles. See How it works.
            ground.frustumCulled = false;
            this.scene.add(ground);

            new GLTFLoader().load(
                'https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf',
                (gltf) => {
                    gltf.scene.traverse((child) => {
                        if (!child.isMesh) return;
                        child.castShadow = true;
                        child.frustumCulled = false;
                    });
                    gltf.scene.scale.set(4, 4, 4);
                    this.scene.add(gltf.scene);
                    setTime(Number(document.getElementById('hour').value));
                }
            );

            this.renderer = new THREE.WebGLRenderer({ canvas: map.getCanvas(), context: gl, antialias: true });
            this.renderer.autoClear = false;
            this.renderer.shadowMap.enabled = true;
            this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        },

        render(gl, args) {
            const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
            const l = new THREE.Matrix4()
                .makeTranslation(originMercator.x, originMercator.y, originMercator.z)
                .scale(new THREE.Vector3(scale, -scale, scale))
                .multiply(new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(1, 0, 0), Math.PI / 2));

            this.camera.projectionMatrix = m.multiply(l);
            this.renderer.resetState();
            this.renderer.render(this.scene, this.camera);
        }
    };

    map.on('style.load', () => map.addLayer(customLayer));

    document.getElementById('hour').addEventListener('input', (e) => setTime(Number(e.target.value)));
    setTime(600);
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Shadows from the Sun - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Drive a shadow from the real solar position." />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
    <script type="importmap">
        {
            "imports": {
                "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
                "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
            }
        }
    </script>
    <style>
        html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
        #map { width: 100%; height: 100%; }
        /* Dims the whole map as the sun goes down. Opacity is driven from sun altitude. */
        #nightfall {
            position: absolute; inset: 0; z-index: 998; pointer-events: none;
            background: #0b1733; opacity: 0; transition: opacity .25s linear;
        }
        #controls {
            position: absolute; bottom: 34px; left: 50%; transform: translateX(-50%);
            z-index: 999; width: min(460px, calc(100% - 24px));
            background: #fff; border-radius: 8px; box-shadow: 0 2px 16px #0003;
            font: 13px/1.5 system-ui, sans-serif; padding: 12px 16px 14px;
        }
        #controls .head {
            display: flex; align-items: baseline; justify-content: space-between;
            margin-bottom: 8px;
        }
        #controls .time { font-size: 17px; font-weight: 600; font-variant-numeric: tabular-nums; }
        #controls .sun { color: #5b6170; font-size: 12px; font-variant-numeric: tabular-nums; }
        #controls input[type=range] { width: 100%; margin: 0; display: block; }
        #controls .scale {
            display: flex; justify-content: space-between;
            color: #9aa1b1; font-size: 11px; margin-top: 2px;
        }
    </style>
</head>
<body>
<div id="map"></div>
<div id="nightfall"></div>
<div id="controls">
    <div class="head">
        <span class="time" id="time"></span>
        <span class="sun" id="sun"></span>
    </div>
    <input type="range" id="hour" min="0" max="1439" step="10" value="600" aria-label="Time of day">
    <div class="scale"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div>
</div>
<script type="module">
    import * as THREE from 'three';
    import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

    const API_KEY = 'YOUR_API_KEY';

    const ORIGIN = [11.39085, 47.27574];
    const DATE = new Date(Date.UTC(2026, 5, 21)); // June solstice, the extreme case

    // --- Solar position, after the standard low-precision NOAA formulas -------------
    const rad = Math.PI / 180;
    const J1970 = 2440588, J2000 = 2451545, dayMs = 86400000;
    const obliquity = rad * 23.4397;

    function sunPosition(date, lat, lng) {
        const days = date.valueOf() / dayMs - 0.5 + J1970 - J2000;

        const meanAnomaly = rad * (357.5291 + 0.98560028 * days);
        const centre = rad * (1.9148 * Math.sin(meanAnomaly) +
                              0.02 * Math.sin(2 * meanAnomaly) +
                              0.0003 * Math.sin(3 * meanAnomaly));
        const eclipticLongitude = meanAnomaly + centre + rad * 102.9372 + Math.PI;

        const declination = Math.asin(Math.sin(obliquity) * Math.sin(eclipticLongitude));
        const rightAscension = Math.atan2(Math.sin(eclipticLongitude) * Math.cos(obliquity),
                                          Math.cos(eclipticLongitude));

        const west = rad * -lng;
        const hourAngle = rad * (280.16 + 360.9856235 * days) - west - rightAscension;
        const phi = rad * lat;

        return {
            altitude: Math.asin(Math.sin(phi) * Math.sin(declination) +
                                Math.cos(phi) * Math.cos(declination) * Math.cos(hourAngle)),
            // Measured from south, turning west. Converted to compass bearing below.
            azimuth: Math.atan2(Math.sin(hourAngle),
                                Math.cos(hourAngle) * Math.sin(phi) - Math.tan(declination) * Math.cos(phi))
        };
    }
    // -------------------------------------------------------------------------------

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: ORIGIN,
        zoom: 17,
        pitch: 60,
        bearing: 20,
        antialias: true,
        attributionControl: { compact: false }
    });

    // terrainControl:false matters here. Left on, the control switches 3D terrain on the
    // first time the map is tilted, and this custom layer draws at a fixed altitude, so the
    // ground would rise through the scene.
    map.addControl(new maptoolkit.NavigationControl({ visualizePitch: true, terrainControl: false }), 'top-right');

    const originMercator = maptoolkit.MercatorCoordinate.fromLngLat(ORIGIN, 0);
    const scale = originMercator.meterInMercatorCoordinateUnits();

    let sunLight = null;

    function setTime(minutes) {
        const when = new Date(DATE.valueOf() + minutes * 60000);
        const { altitude, azimuth } = sunPosition(when, ORIGIN[1], ORIGIN[0]);

        const compass = (azimuth * 180 / Math.PI + 180) % 360;
        const elevation = altitude * 180 / Math.PI;

        document.getElementById('time').textContent =
            `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')} UTC`;
        document.getElementById('sun').textContent = elevation > 0
            ? `sun ${elevation.toFixed(0)}° above horizon, bearing ${compass.toFixed(0)}°`
            : `sun ${Math.abs(elevation).toFixed(0)}° below horizon`;

        // Darken the map as the sun sets. Civil twilight is about 6 degrees below the
        // horizon, so fade across that band rather than switching at zero.
        const darkness = Math.min(1, Math.max(0, (6 - elevation) / 18));
        document.getElementById('nightfall').style.opacity = (darkness * 0.62).toFixed(3);

        if (!sunLight) return;

        // Scene axes: X east, Z south, Y up. Distance is arbitrary for a directional light.
        const d = 400;
        sunLight.position.set(
            d * Math.cos(altitude) * Math.sin(azimuth),
            d * Math.sin(altitude),
            d * Math.cos(altitude) * Math.cos(azimuth)
        );
        // Below the horizon there is no direct light, so no shadow either.
        sunLight.intensity = Math.max(0, Math.sin(altitude)) * 3;
        map.triggerRepaint();
    }

    const customLayer = {
        id: 'sun-shadow',
        type: 'custom',
        renderingMode: '3d',

        onAdd(map, gl) {
            this.map = map;
            this.camera = new THREE.Camera();
            this.scene = new THREE.Scene();

            this.scene.add(new THREE.AmbientLight(0xbfd4ff, 0.9));

            sunLight = new THREE.DirectionalLight(0xfff3e0, 3);
            sunLight.castShadow = true;
            // Tight bounds around the model, not the scene: this is the sharpness dial.
            const c = sunLight.shadow.camera;
            c.left = c.bottom = -60; c.right = c.top = 60;
            c.near = 1; c.far = 900;
            sunLight.shadow.mapSize.set(2048, 2048);
            sunLight.shadow.bias = -0.003;
            this.scene.add(sunLight);
            this.scene.add(sunLight.target);

            // An invisible plane that receives the shadow and nothing else.
            const ground = new THREE.Mesh(
                new THREE.PlaneGeometry(600, 600),
                new THREE.ShadowMaterial({ opacity: 0.4 })
            );
            ground.rotateX(-Math.PI / 2);
            ground.receiveShadow = true;
            // MapLibre supplies the projection matrix, so three.js cannot derive a correct
            // frustum from it and culls at some tilt and bearing angles. See How it works.
            ground.frustumCulled = false;
            this.scene.add(ground);

            new GLTFLoader().load(
                'https://maplibre.org/maplibre-gl-js/docs/assets/34M_17/34M_17.gltf',
                (gltf) => {
                    gltf.scene.traverse((child) => {
                        if (!child.isMesh) return;
                        child.castShadow = true;
                        child.frustumCulled = false;
                    });
                    gltf.scene.scale.set(4, 4, 4);
                    this.scene.add(gltf.scene);
                    setTime(Number(document.getElementById('hour').value));
                }
            );

            this.renderer = new THREE.WebGLRenderer({ canvas: map.getCanvas(), context: gl, antialias: true });
            this.renderer.autoClear = false;
            this.renderer.shadowMap.enabled = true;
            this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        },

        render(gl, args) {
            const m = new THREE.Matrix4().fromArray(args.defaultProjectionData.mainMatrix);
            const l = new THREE.Matrix4()
                .makeTranslation(originMercator.x, originMercator.y, originMercator.z)
                .scale(new THREE.Vector3(scale, -scale, scale))
                .multiply(new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(1, 0, 0), Math.PI / 2));

            this.camera.projectionMatrix = m.multiply(l);
            this.renderer.resetState();
            this.renderer.render(this.scene, this.camera);
        }
    };

    map.on('style.load', () => map.addLayer(customLayer));

    document.getElementById('hour').addEventListener('input', (e) => setTime(Number(e.target.value)));
    setTime(600);
</script>
</body>
</html>

Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.

Use the Maptoolkit Connector. Create an interactive 3D map with a glTF model that casts a shadow, where the light direction is computed from the real solar position for the map location and a time-of-day slider.

How it works

The solar position is the low-precision NOAA algorithm, accurate to well under a degree, which is far finer than a shadow on a map needs. It takes a UTC instant, a latitude and a longitude, and returns altitude (height above the horizon) and azimuth. Both are needed: altitude alone gives shadow length, azimuth gives direction, and a study that gets one right and the other wrong is worse than no study.

Watch the azimuth convention. The formula returns an angle measured from south, turning west, which is the astronomical convention and not the compass bearing people expect. Adding 180 and wrapping converts it, and the readout shows the converted value. Feeding the raw angle into a compass display puts the midday sun in the north.

The date is fixed to the June solstice deliberately. Solar geometry varies far more across the year than across the day at a given hour, so a shading study anchored to an arbitrary date answers a question nobody asked. The solstices and equinoxes are the cases that matter.

frustumCulled = false is required on everything in the scene. three.js builds its frustum from camera.projectionMatrix and camera.matrixWorldInverse, and in a MapLibre custom layer the projection matrix arrives fully composed while the camera’s own transform is identity. The frustum three.js derives therefore does not describe the real view, and objects are culled at some tilt and bearing angles: the model vanishes as soon as you rotate the map, which reads as the layer breaking rather than as a culling bug.

The map darkens with the sun. A shadow study that keeps the basemap at full daylight brightness at 2 a.m. is telling two different stories at once, so #nightfall is a plain overlay whose opacity follows sun altitude. The fade runs across civil twilight rather than switching at zero, because the light does not stop at the horizon: there is usable daylight until roughly 6 degrees below it. The overlay sits above the map and below the controls, with pointer-events: none so it never intercepts a drag.

intensity follows sin(altitude) and clamps at zero. Below the horizon there is no direct light, so the shadow disappears rather than inverting, which is what happens if you keep the light on and let it come from underneath.

Shadow camera bounds are the sharpness dial. A directional light’s shadow map covers the box you give it, so the same 2048 pixel map spread over a 1200 metre box is four times blurrier than over a 120 metre one. Tightening the bounds around the model rather than the scene is the first thing to try when shadow edges look blocky, and it costs nothing.

shadow.bias at a small negative value removes shadow acne, the stippled self-shadowing that appears on surfaces facing the light. Too large a bias detaches the shadow from the object instead, so it is worth tuning by eye.

The ground is a ShadowMaterial plane. It renders nothing except the shadow falling on it, so the basemap shows through and the shadow lands on the map rather than on a grey rectangle.

Next steps

The version that answers a real question sweeps the time rather than setting it: stepping through a day and accumulating where the shadow falls produces a sun-hours map, which is what planning and solar assessment actually want.

Real buildings are the other half. Extruded footprints from the vector tiles give you a city to cast shadows from, though note that they are a separate renderer from three.js and will not receive this light. On uneven ground the model also has to sit on the terrain rather than at sea level.