Skip to content

3D Building with Shadow

Shadow Length: 1.0x
Buildings: 0
Status: Loading...
html
<!DOCTYPE html>
<html>
  <head>
    <title>3D Buildings with Shadows</title>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="initial-scale=1,maximum-scale=1,user-scalable=no"
    />
    <link
      href="https://cdn.mapmetrics-atlas.net/versions/latest/mapmetrics-gl.css"
      rel="stylesheet"
    />
    <script src="https://cdn.mapmetrics-atlas.net/versions/latest/mapmetrics-gl.js"></script>
    <style>
      body {
        margin: 0;
        padding: 0;
      }
      #map {
        position: absolute;
        top: 0;
        bottom: 0;
        width: 100%;
      }

      .controls {
        position: absolute;
        top: 10px;
        right: 10px;
        background: white;
        padding: 15px;
        border-radius: 5px;
        box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
        z-index: 1000;
        max-width: 300px;
      }

      .control-group {
        margin: 10px 0;
        padding: 10px 0;
        border-bottom: 1px solid #eee;
      }

      h3 {
        margin: 0 0 10px 0;
      }

      label {
        display: block;
        margin: 5px 0;
        font-size: 12px;
      }

      input[type="range"] {
        width: 100%;
      }

      button {
        padding: 8px 12px;
        margin: 2px;
        border: 1px solid #ccc;
        border-radius: 3px;
        background: #f9f9f9;
        cursor: pointer;
      }

      button:hover {
        background: #e9e9e9;
      }

      .info {
        position: absolute;
        bottom: 10px;
        left: 10px;
        background: white;
        padding: 10px;
        border-radius: 5px;
        box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
        font-family: monospace;
        font-size: 11px;
      }

      .shadow-style-btns {
        display: flex;
        gap: 5px;
        margin-top: 10px;
      }

      .shadow-style-btns button {
        flex: 1;
        font-size: 11px;
      }
    </style>
  </head>
  <body>
    <div id="map"></div>

    <div class="controls">
      <h3>3D Buildings with Shadows</h3>

      <div class="control-group">
        <label>
          <input type="checkbox" id="shadowToggle" checked /> Enable Shadows
        </label>
      </div>

      <div class="control-group">
        <label>Sun Azimuth: <span id="azimuthValue">180</span>°</label>
        <input
          type="range"
          id="azimuthSlider"
          min="0"
          max="360"
          value="180"
          step="1"
        />

        <label>Sun Altitude: <span id="altitudeValue">45</span>°</label>
        <input
          type="range"
          id="altitudeSlider"
          min="5"
          max="85"
          value="45"
          step="1"
        />
      </div>

      <div class="control-group">
        <label>Shadow Intensity: <span id="intensityValue">50</span>%</label>
        <input
          type="range"
          id="intensitySlider"
          min="0"
          max="100"
          value="50"
          step="5"
        />
      </div>

      <div class="shadow-style-btns">
        <button onclick="setSunPosition(90, 15)">Morning</button>
        <button onclick="setSunPosition(180, 70)">Noon</button>
        <button onclick="setSunPosition(270, 15)">Evening</button>
      </div>
    </div>

    <div class="info">
      <div>Shadow Length: <span id="shadowLength">1.0</span>x</div>
      <div>Buildings: <span id="buildingCount">0</span></div>
      <div>Status: <span id="status">Loading...</span></div>
    </div>

    <script>
      const token = `YOUR_TOKEN`;
      // Initialize map with the MapMetrics Atlas style
      const map = new mapmetricsgl.Map({
        container: "map",
        style: `https://gateway.mapmetrics-atlas.net/styles/?fileName=${token}`,
        center: [5.47, 51.49], // Center of your building data
        zoom: 16,
        pitch: 60,
        bearing: -30,
      });

      let shadowsEnabled = true;
      let currentAzimuth = 180;
      let currentAltitude = 45;
      let currentIntensity = 50; // Shadow intensity as percentage (0=white, 100=black)

      map.on("load", () => {
        console.log("Map loaded");
        document.getElementById("status").textContent = "Map loaded";

        // Add the 3D buildings source
        map.addSource("buildings", {
          type: "vector",
          tiles: [
            "https://building.mapmetrics-atlas.net/data/buildings/{z}/{x}/{y}.pbf",
          ],
          minzoom: 13,
          maxzoom: 16,
        });

        // Add 3D buildings layer
        map.addLayer({
          id: "buildings-3d",
          type: "fill-extrusion",
          source: "buildings",
          "source-layer": "building",
          paint: {
            "fill-extrusion-color": "#aaa",
            "fill-extrusion-height": [
              "case",
              ["has", "height"],
              ["get", "height"],
              ["has", "estimated_height"],
              ["get", "estimated_height"],
              ["has", "building:levels"],
              ["*", ["to-number", ["get", "building:levels"]], 3],
              ["has", "floors"],
              ["*", ["to-number", ["get", "floors"]], 3],
              10,
            ],
            "fill-extrusion-base": 0,
            "fill-extrusion-opacity": 0.9,
          },
        });

        // Add shadow layers
        addShadowLayers();

        document.getElementById("status").textContent =
          "Buildings & Shadows Ready";

        // Initial shadow update
        updateShadows();
        updateBuildingCount();
      });

      function addShadowLayers() {
        // Create a GeoJSON source for shadow polygons
        map.addSource("shadow-polygons", {
          type: "geojson",
          data: {
            type: "FeatureCollection",
            features: [],
          },
        });

        // Add shadow polygon layer - control intensity via color, not opacity
        map.addLayer(
          {
            id: "shadow-polygons",
            type: "fill",
            source: "shadow-polygons",
            paint: {
              "fill-color": "#808080", // Grey color (will be adjustable)
              "fill-opacity": 0.7, // Fixed opacity
            },
          },
          "buildings-3d"
        ); // Place shadows before buildings
      }

      function generateShadowPolygons() {
        const features = [];

        try {
          const buildings = map.queryRenderedFeatures({
            layers: ["buildings-3d"],
          });

          if (!buildings || buildings.length === 0) return features;

          const azimuthRad = (currentAzimuth * Math.PI) / 180;
          const altitudeRad = Math.max(0.01, (currentAltitude * Math.PI) / 180);

          // Calculate physically accurate shadow length
          let shadowLength = 1 / Math.tan(altitudeRad);
          shadowLength = Math.min(10, shadowLength); // Cap at 10x height
          if (currentAltitude < 5) {
            shadowLength *= 0.5; // Reduce for very low sun
          }

          // Get current map center for accurate world coordinates
          const center = map.getCenter();
          const currentLat = center.lat;

          // Calculate shadow offset in world coordinates (degrees)
          const metersPerDegLat = 111320;
          const metersPerDegLng =
            111320 * Math.cos((currentLat * Math.PI) / 180);

          // Convert shadow direction to degrees
          const shadowDirLat =
            (Math.cos(azimuthRad) * shadowLength) / metersPerDegLat;
          const shadowDirLng =
            (-Math.sin(azimuthRad) * shadowLength) / metersPerDegLng;

          buildings.forEach((building) => {
            if (!building.geometry || building.geometry.type !== "Polygon")
              return;

            const props = building.properties || {};
            const height =
              props.height ||
              props.estimated_height ||
              (props["building:levels"]
                ? parseFloat(props["building:levels"]) * 3
                : 10) ||
              (props.floors ? parseFloat(props.floors) * 3 : 10);

            const footprint = building.geometry.coordinates[0];
            if (!footprint || footprint.length < 3) return;

            // Create shadow polygon (projected footprint) in world coordinates
            const shadowFootprint = footprint.map((coord) => [
              coord[0] + shadowDirLng * height,
              coord[1] + shadowDirLat * height,
            ]);

            // Create the main shadow polygon
            features.push({
              type: "Feature",
              geometry: {
                type: "Polygon",
                coordinates: [shadowFootprint],
              },
            });

            // Create side faces of the shadow volume
            for (let i = 0; i < footprint.length - 1; i++) {
              const nextI = (i + 1) % (footprint.length - 1);
              features.push({
                type: "Feature",
                geometry: {
                  type: "Polygon",
                  coordinates: [
                    [
                      footprint[i],
                      footprint[nextI],
                      shadowFootprint[nextI],
                      shadowFootprint[i],
                      footprint[i],
                    ],
                  ],
                },
              });
            }
          });
        } catch (error) {
          console.error("Error generating shadow polygons:", error);
        }

        return {
          type: "FeatureCollection",
          features: features,
        };
      }

      function updateShadows() {
        // Update polygon shadows
        if (map.getSource("shadow-polygons")) {
          const shadowData = shadowsEnabled
            ? generateShadowPolygons()
            : {
                type: "FeatureCollection",
                features: [],
              };
          map.getSource("shadow-polygons").setData(shadowData);

          // Update visibility
          map.setLayoutProperty(
            "shadow-polygons",
            "visibility",
            shadowsEnabled ? "visible" : "none"
          );

          // Calculate grey color based on intensity (0=white, 100=black)
          const greyValue = Math.round(255 - (currentIntensity / 100) * 255);
          const hexGrey = greyValue.toString(16).padStart(2, "0");
          const shadowColor = `#${hexGrey}${hexGrey}${hexGrey}`;

          // Update shadow color, keeping opacity fixed at 0.7
          map.setPaintProperty("shadow-polygons", "fill-color", shadowColor);
          map.setPaintProperty("shadow-polygons", "fill-opacity", 0.7);
        }

        // Calculate and display shadow length
        const altitudeRad = Math.max(0.1, (currentAltitude * Math.PI) / 180);
        const shadowLength = Math.min(3, 1 / Math.tan(altitudeRad));
        document.getElementById("shadowLength").textContent =
          shadowLength.toFixed(2);
      }

      function setSunPosition(azimuth, altitude) {
        currentAzimuth = azimuth;
        currentAltitude = altitude;
        document.getElementById("azimuthSlider").value = azimuth;
        document.getElementById("altitudeSlider").value = altitude;
        document.getElementById("azimuthValue").textContent = azimuth;
        document.getElementById("altitudeValue").textContent = altitude;
        updateShadows();
      }

      function updateBuildingCount() {
        const features = map.queryRenderedFeatures({
          layers: ["buildings-3d"],
        });
        document.getElementById("buildingCount").textContent = features.length;
      }

      // Event listeners
      document
        .getElementById("shadowToggle")
        .addEventListener("change", (e) => {
          shadowsEnabled = e.target.checked;
          updateShadows();
        });

      document
        .getElementById("azimuthSlider")
        .addEventListener("input", (e) => {
          currentAzimuth = parseFloat(e.target.value);
          document.getElementById("azimuthValue").textContent = currentAzimuth;
          updateShadows();
        });

      document
        .getElementById("altitudeSlider")
        .addEventListener("input", (e) => {
          currentAltitude = parseFloat(e.target.value);
          document.getElementById("altitudeValue").textContent =
            currentAltitude;
          updateShadows();
        });

      document
        .getElementById("intensitySlider")
        .addEventListener("input", (e) => {
          currentIntensity = parseInt(e.target.value);
          document.getElementById("intensityValue").textContent =
            currentIntensity;
          updateShadows();
        });

      // Update on map movement
      map.on("moveend", () => {
        updateShadows();
        updateBuildingCount();
      });

      // Add navigation controls
      map.addControl(new mapmetricsgl.NavigationControl());

      // Add popup on click for building info
      map.on("click", "buildings-3d", (e) => {
        const coordinates = e.lngLat;
        const properties = e.features[0].properties;

        const actualHeight =
          properties.height ||
          properties.estimated_height ||
          (properties["building:levels"]
            ? parseInt(properties["building:levels"]) * 3
            : null) ||
          (properties.floors ? parseInt(properties.floors) * 3 : null) ||
          10;

        const popupContent = `
                <strong>Building Info:</strong><br>
                Height: ${actualHeight}m<br>
                Levels: ${properties["building:levels"] || "N/A"}<br>
                Type: ${
                  properties["building"] || properties["building:type"] || "N/A"
                }<br>
                Name: ${properties.name || "N/A"}
            `;

        new mapmetricsgl.Popup()
          .setLngLat(coordinates) // place popup at clicked point
          .setHTML(popupContent) // set the content
          .addTo(map); // add popup to the map
      });

      // Change cursor on hover
      map.on("mouseenter", "buildings-3d", () => {
        map.getCanvas().style.cursor = "pointer";
      });

      map.on("mouseleave", "buildings-3d", () => {
        map.getCanvas().style.cursor = "";
      });
    </script>
  </body>
</html>