Display a globe-shaped Map.

Parameters

00:00
12:00
24:00
100% © OpenStreetMap contributors, © NASA, © Mapbox, Made with Natural Earth., SolarSystemsScope
index.js
import { AmbientLight, DirectionalLight, MathUtils, Vector3 } from "three";

import { TopoJSON } from "ol/format.js";
import OSM from "ol/source/OSM.js";
import XYZ from "ol/source/XYZ.js";
import { Fill, Style } from "ol/style.js";

import GlobeControls from "@giro3d/giro3d/controls/GlobeControls.js";
import ColorMap from "@giro3d/giro3d/core/ColorMap.js";
import Ellipsoid from "@giro3d/giro3d/core/geographic/Ellipsoid.js";
import Extent from "@giro3d/giro3d/core/geographic/Extent.js";
import Sun from "@giro3d/giro3d/core/geographic/Sun.js";
import Instance from "@giro3d/giro3d/core/Instance.js";
import BlendingMode from "@giro3d/giro3d/core/layer/BlendingMode.js";
import ColorLayer from "@giro3d/giro3d/core/layer/ColorLayer.js";
import ElevationLayer from "@giro3d/giro3d/core/layer/ElevationLayer.js";
import Atmosphere from "@giro3d/giro3d/entities/Atmosphere.js";
import Globe from "@giro3d/giro3d/entities/Globe.js";
import Glow from "@giro3d/giro3d/entities/Glow.js";
import SphericalPanorama from "@giro3d/giro3d/entities/SphericalPanorama.js";
import MapboxTerrainFormat from "@giro3d/giro3d/formats/MapboxTerrainFormat.js";
import Inspector from "@giro3d/giro3d/gui/Inspector.js";
import GeoTIFFSource from "@giro3d/giro3d/sources/GeoTIFFSource.js";
import StaticImageSource from "@giro3d/giro3d/sources/StaticImageSource.js";
import TiledImageSource from "@giro3d/giro3d/sources/TiledImageSource.js";
import VectorSource from "@giro3d/giro3d/sources/VectorSource.js";
import GlobeControlsInspector from "@giro3d/giro3d/gui/GlobeControlsInspector.js";

function bindButton(id, onClick) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLButtonElement)) {
    throw new Error(
      "invalid binding element: expected HTMLButtonElement, got: " +
        element.constructor.name,
    );
  }

  element.onclick = () => {
    onClick(element);
  };

  return element;
}

function bindColorPicker(id, onChange) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLInputElement)) {
    throw new Error(
      "invalid binding element: expected HTMLInputElement, got: " +
        element.constructor.name,
    );
  }

  element.oninput = function oninput() {
    // Let's change the classification color with the color picker value
    const hexColor = element.value;
    onChange(new Color(hexColor));
  };

  const externalFunction = (v) => {
    element.value = `#${new Color(v).getHexString()}`;
    onChange(element.value);
  };

  return [externalFunction, new Color(element.value), element];
}

function bindDatePicker(id, onChange) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLInputElement)) {
    throw new Error(
      "invalid binding element: expected HTMLInputElement, got: " +
        element.constructor.name,
    );
  }

  element.onchange = () => {
    onChange(new Date(element.value));
  };

  const callback = (v) => {
    const clone = new Date(v.getTime());
    v.setMinutes(v.getMinutes() - v.getTimezoneOffset());
    element.value = clone.toISOString().slice(0, 10);
    onChange(new Date(element.value));
  };

  return [callback, new Date(element.value), element];
}

function bindDropDown(id, onChange) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLSelectElement)) {
    throw new Error(
      "invalid binding element: expected HTMLSelectElement, got: " +
        element.constructor.name,
    );
  }

  element.onchange = () => {
    onChange(element.value);
  };

  const callback = (v) => {
    element.value = v;
    onChange(element.value);
  };

  const setOptions = (options) => {
    const items = options.map(
      (opt) =>
        `<option value=${opt.id} ${opt.selected ? "selected" : ""}>${opt.name}</option>`,
    );
    element.innerHTML = items.join("\n");
  };

  return [callback, element.value, element, setOptions];
}

function bindSlider(id, onChange) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLInputElement)) {
    throw new Error(
      "invalid binding element: expected HTMLInputElement, got: " +
        element.constructor.name,
    );
  }

  element.oninput = function oninput() {
    onChange(element.valueAsNumber);
  };

  const setValue = (v, min, max, step) => {
    if (min != null && max != null) {
      element.min = min.toString();
      element.max = max.toString();

      if (step != null) {
        element.step = step;
      }
    }
    element.valueAsNumber = v;
    onChange(element.valueAsNumber);
  };

  const initialValue = element.valueAsNumber;

  return [setValue, initialValue, element];
}

function bindToggle(id, onChange) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLInputElement)) {
    throw new Error(
      "invalid binding element: expected HTMLButtonElement, got: " +
        element.constructor.name,
    );
  }

  element.oninput = function oninput() {
    onChange(element.checked);
  };

  const callback = (v) => {
    element.checked = v;
    onChange(element.checked);
  };

  return [callback, element.checked, element];
}

function makeColorRamp(
  preset,
  discrete = false,
  invert = false,
  mirror = false,
) {
  let nshades = discrete ? 10 : 256;

  const values = colormap({ colormap: preset, nshades });

  const colors = values.map((v) => new Color(v));

  if (invert) {
    colors.reverse();
  }

  if (mirror) {
    const mirrored = [...colors, ...colors.reverse()];
    return mirrored;
  }

  return colors;
}

function updateLabel(id, text) {
  const element = document.getElementById(id);
  if (!(element instanceof HTMLLabelElement)) {
    throw new Error(
      "invalid binding element: expected HTMLLabelElement, got: " +
        element.constructor.name,
    );
  }

  element.innerText = text;
}

const instance = new Instance({
  target: "view",
  crs: "EPSG:4978",
  backgroundColor: "black",
});

/////////////////////////////// Globe creations ///////////////////////////////////////////////////

const earth = new Globe({
  lighting: {
    enabled: true,
  },
  graticule: {
    enabled: true,
    color: "black",
    xStep: 10, // In degrees
    yStep: 10, // In degrees
    xOffset: 0,
    yOffset: 0,
    opacity: 0.5,
    thickness: 0.5, // In degrees
  },
  backgroundColor: "#001B35",
});

earth.name = "Earth";

instance.add(earth);

const moon = new Globe({
  lighting: {
    enabled: true,
  },
  graticule: {
    enabled: true,
    color: "black",
    xStep: 10, // In degrees
    yStep: 10, // In degrees
    xOffset: 0,
    yOffset: 0,
    opacity: 0.5,
    thickness: 0.5, // In degrees
  },
  backgroundColor: "grey",
  // For the moon we use a custom ellipsoid
  ellipsoid: new Ellipsoid({
    semiMajorAxis: 1_738_100,
    semiMinorAxis: 1_736_000,
  }),
});

moon.name = "Moon";

instance.add(moon);

const moonLayer = new ColorLayer({
  source: new GeoTIFFSource({
    url: "https://3d.oslandia.com/giro3d/rasters/moon.tif",
    crs: "EPSG:4326",
  }),
});

moon.addLayer(moonLayer);

const mars = new Globe({
  lighting: {
    enabled: true,
  },
  graticule: {
    enabled: true,
    color: "black",
    xStep: 10, // In degrees
    yStep: 10, // In degrees
    xOffset: 0,
    yOffset: 0,
    opacity: 0.5,
    thickness: 0.5, // In degrees
  },
  backgroundColor: "#C64600",
  // For Mars we use a custom ellipsoid
  // See https://tharsis.gsfc.nasa.gov/geodesy.html
  ellipsoid: new Ellipsoid({
    semiMajorAxis: 3_396_200,
    semiMinorAxis: 3_376_189,
  }),
});

mars.name = "Mars";

instance.add(mars);

const marsLayer = new ColorLayer({
  source: new GeoTIFFSource({
    // From https://www.solarsystemscope.com/textures/
    url: "https://3d.oslandia.com/giro3d/rasters/8k_mars.tif",
    crs: "EPSG:4326",
  }),
});

mars.addLayer(marsLayer);

// The sun is so huge that it would be impractical
// to display it in its actual scale.
const SUN_SIZE_FACTOR = 0.1;

const sun = new Globe({
  lighting: {
    enabled: false,
  },
  graticule: {
    enabled: true,
    color: "black",
    xStep: 10, // In degrees
    yStep: 10, // In degrees
    xOffset: 0,
    yOffset: 0,
    opacity: 0.5,
    thickness: 0.5, // In degrees
  },
  backgroundColor: "grey",
  // For the sun we use a spherical ellipsoid
  ellipsoid: new Ellipsoid({
    semiMajorAxis: 696_340_000 * SUN_SIZE_FACTOR,
    semiMinorAxis: 696_340_000 * SUN_SIZE_FACTOR,
  }),
});

sun.name = "Sun";

instance.add(sun);

const sunLayer = new ColorLayer({
  source: new GeoTIFFSource({
    // From https://www.solarsystemscope.com/textures/
    url: "https://3d.oslandia.com/giro3d/rasters/8k_sun.tif",
    crs: "EPSG:4326",
  }),
});

sun.addLayer(sunLayer);

const allGlobes = [earth, moon, mars, sun];

/////////////////////////////// Star background /////////////////////////////////////////////////

const background = new SphericalPanorama({
  radius: 10_000_000,
  subdivisionThreshold: 0.4,
  depthTest: false,
});
background.name = "background";
background.renderOrder = -9999;
instance.add(background);

const starLayer = new ColorLayer({
  source: new StaticImageSource({
    source: "https://3d.oslandia.com/giro3d/images/4k_stars_milky_way.jpg",
    extent: Extent.fullEquirectangularProjection,
  }),
});

background.addLayer(starLayer);

/////////////////////////////// Earth layers ////////////////////////////////////////////////////

const mapboxApiKey =
  "pk.eyJ1IjoidG11Z3VldCIsImEiOiJjbGJ4dTNkOW0wYWx4M25ybWZ5YnpicHV6In0.KhDJ7W5N3d1z3ArrsDjX_A";

// Adds a XYZ elevation layer with MapBox terrain RGB tileset
const elevationLayer = new ElevationLayer({
  name: "elevation",
  preloadImages: true,
  colorMap: new ColorMap({
    colors: makeColorRamp("greens"),
    min: -1500,
    max: 6000,
  }),
  minmax: { min: -500, max: 8000 },
  // We dont want the full resolution because the terrain
  // mesh has a much lower resolution than the raster image
  resolutionFactor: 1 / 8,
  source: new TiledImageSource({
    retries: 0,
    format: new MapboxTerrainFormat(),
    source: new XYZ({
      url: `https://api.mapbox.com/v4/mapbox.terrain-rgb/{z}/{x}/{y}.pngraw?access_token=${mapboxApiKey}`,
      projection: "EPSG:3857",
    }),
  }),
});
earth.addLayer(elevationLayer).catch(console.error);

const watermask = new ColorLayer({
  name: "watermask",
  source: new VectorSource({
    dataProjection: "EPSG:4326",
    data: {
      url: "https://3d.oslandia.com/giro3d/vectors/water_mask.topojson",
      format: new TopoJSON(),
    },
    style: new Style({
      fill: new Fill({
        color: "#22274a",
      }),
    }),
  }),
});

earth.addLayer(watermask);

// Adds a XYZ color layer with MapBox satellite tileset
const satellite = new ColorLayer({
  name: "satellite",
  preloadImages: true,
  source: new TiledImageSource({
    source: new XYZ({
      url: `https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.webp?access_token=${mapboxApiKey}`,
      projection: "EPSG:3857",
      crossOrigin: "anonymous",
    }),
  }),
});
earth.addLayer(satellite).catch((e) => console.error(e));

// Create the OpenStreetMap color layer using an OpenLayers source.
// See https://openlayers.org/en/latest/apidoc/module-ol_source_OSM-OSM.html
// for more informations.
const osm = new ColorLayer({
  name: "OSM",
  source: new TiledImageSource({ source: new OSM() }),
});
earth.addLayer(osm).catch((e) => console.error(e));

const clouds = new ColorLayer({
  name: "clouds",
  blendingMode: BlendingMode.Add,
  source: new StaticImageSource({
    source: "https://3d.oslandia.com/giro3d/images/cloud_cover.webp",
    extent: Extent.WGS84,
  }),
});
earth.addLayer(clouds).catch(console.error);

/////////////////////////////// Lighting //////////////////////////////////////////////////////

// Let's add a sun in our scene
const sunlight = new DirectionalLight("white", 4);
sunlight.name = "sun";

instance.add(sunlight);
instance.add(sunlight.target);

sunlight.updateMatrixWorld(true);

const ambientLight = new AmbientLight("white", 0.3);
instance.add(ambientLight);

/////////////////////////////// Atmospheres //////////////////////////////////////////////////

const earthAtmosphere = new Atmosphere({ ellipsoid: earth.ellipsoid });
earthAtmosphere.name = "Earth atmosphere";
instance.add(earthAtmosphere);

const marsAtmosphere = new Atmosphere({
  ellipsoid: mars.ellipsoid,
  wavelengths: [0.414, 0.443, 0.475], // To give the atmosphere the rusty color of Mars
});
marsAtmosphere.name = "Mars atmosphere";
instance.add(marsAtmosphere);

// For the sun we don't use an atmosphere, but a glow
const sunGlow = new Glow({
  color: "#ff7800",
  ellipsoid: sun.ellipsoid,
});

sunGlow.name = "sun glow";

instance.add(sunGlow);

/////////////////////////////// Camera & controls ///////////////////////////////////////////

const defaultCameraPosition = new Vector3(
  35_785_000 + Ellipsoid.WGS84.semiMajorAxis,
  0,
  0,
);

// Geostationary orbit at 36,000 km
instance.view.camera.position.copy(defaultCameraPosition);
instance.view.camera.lookAt(new Vector3(0, 0, 0));

let controls;

const updateControls = () => {
  if (controls) {
    controls.update();
    instance.notifyChange();
  }

  requestAnimationFrame(updateControls);
};

updateControls();

/////////////////////////////// Example GUI bindings ///////////////////////////////////////////

const [setGraticule] = bindToggle("graticule", (enabled) => {
  allGlobes.forEach((g) => (g.graticule.enabled = enabled));
  instance.notifyChange(allGlobes);
});

setGraticule(earth.graticule.enabled);

const [setAtmosphere] = bindToggle("atmosphere", (enabled) => {
  earthAtmosphere.visible = enabled && earth.visible;
  marsAtmosphere.visible = enabled && mars.visible;
  sunGlow.visible = enabled && sun.visible;

  instance.notifyChange([earthAtmosphere, marsAtmosphere, sunGlow]);
});

const getActiveGlobe = () => {
  return allGlobes.find((g) => g.visible);
};

function update() {
  const globe = getActiveGlobe();

  if (globe == null) {
    return;
  }

  const { x, y, z } = instance.view.camera.position;
  let altitude = globe.ellipsoid.toGeodetic(x, y, z).altitude;
  altitude = MathUtils.clamp(altitude, 2, +Infinity);

  // Let's adjust the graticule step and thickness so that
  // it more or less always look the same when altitude changes.
  if (earth.graticule.enabled) {
    let step = 0;
    if (altitude > 10_000_000) {
      step = 10;
    } else if (altitude > 3_000_000) {
      step = 5;
    } else if (altitude > 1_000_000) {
      step = 2;
    } else if (altitude > 500_000) {
      step = 1;
    } else {
      step = 0.5;
    }

    const thickness = MathUtils.mapLinear(
      altitude,
      200,
      39_000_000,
      0.002,
      0.9,
    );

    earth.graticule.xStep = step;
    earth.graticule.yStep = step;
    earth.graticule.thickness = thickness;
  }

  // Let's make the clouds transparent when we zoom in.
  const opacity = MathUtils.mapLinear(altitude, 12_000_000, 30_000_000, 0, 1);
  clouds.opacity = MathUtils.clamp(opacity, 0, 1);
  earthAtmosphere.opacity = clouds.opacity;

  // Let's increase the shading on the terrain when we zoom out
  const zFactor = MathUtils.mapLinear(altitude, 12_000_000, 30_000_000, 1, 10);
  earth.lighting.zFactor = MathUtils.clamp(zFactor, 1, 10);

  background.object3d.position.set(x, y, z);
  background.object3d.updateMatrixWorld(true);
}

update();

const updateColorMap = () => {
  const minmax = earth.getElevationMinMaxForVisibleTiles();

  if (minmax != null && isFinite(minmax.min) && isFinite(minmax.max)) {
    const colorMap = elevationLayer.colorMap;
    colorMap.min = MathUtils.lerp(minmax.min, colorMap.min, 0.8);
    colorMap.max = MathUtils.lerp(minmax.max, colorMap.max, 0.8);

    instance.notifyChange(elevationLayer);
  }
};

setInterval(updateColorMap, 50);

instance.addEventListener("after-camera-update", update);

const sunParams = {
  latitude: 9,
  longitude: -41,
};

const updateSunDirection = (latitude, longitude) => {
  const position = Ellipsoid.WGS84.toCartesian(
    sunParams.latitude,
    sunParams.longitude,
    50_000_000,
  );

  sunlight.position.copy(position);
  sunlight.target.position.set(0, 0, 0);
  sunlight.target.updateMatrixWorld(true);
  sunlight.updateMatrixWorld(true);

  const normal = Ellipsoid.WGS84.getNormal(
    sunParams.latitude,
    sunParams.longitude,
  );
  earthAtmosphere.setSunPosition(position);
  marsAtmosphere.setSunPosition(position);
};

const [setSunLatitude] = bindSlider("sunLatitude", (lat) => {
  sunParams.latitude = lat;
  updateSunDirection(sunParams.latitude, sunParams.longitude);
  updateLabel(
    "sunLatitudeLabel",
    `Lat: ${Math.round(Math.abs(lat))}° ${lat >= 0 ? "N" : "S"}`,
  );
});

const [setSunLongitude] = bindSlider("sunLongitude", (lon) => {
  sunParams.longitude = lon;
  updateSunDirection(sunParams.latitude, sunParams.longitude);
  updateLabel(
    "sunLongitudeLabel",
    `Lon: ${Math.round(Math.abs(lon))} ${lon >= 0 ? "E" : "W"}°`,
  );
});

const [setLighting] = bindToggle("lighting", (enabled) => {
  earth.lighting.enabled = enabled;
  document.getElementById("lightingParams").style.display = enabled
    ? "block"
    : "none";
  instance.notifyChange(earth);
});

function setSunPosition(date) {
  const sunPosition = Sun.getGeographicPosition(date);

  setSunLongitude(sunPosition.longitude);
  setSunLatitude(sunPosition.latitude);
}

let date = new Date();

const [setDate] = bindDatePicker("date", (date) => {
  setSunPosition(date);
});

const [setTime] = bindSlider("time", (seconds) => {
  const h = seconds / 3600;
  const wholeH = Math.floor(h);

  const m = (h - wholeH) * 60;
  const wholeM = Math.floor(m);

  date.setUTCHours(wholeH, wholeM);

  setSunPosition(date);

  document.getElementById("timeLabel").innerText =
    `${wholeH.toString().padStart(2, "0")}:${wholeM.toString().padStart(2, "0")} UTC`;
});

const setCurrentDate = (date) => {
  setSunPosition(date);
  setDate(date);
  setTime(
    date.getUTCHours() * 3600 +
      date.getUTCMinutes() * 60 +
      date.getUTCSeconds(),
  );
};

bindButton("now", () => {
  date = new Date();
  setCurrentDate(date);
});

const [setSunPositionMode] = bindDropDown("sun-position-mode", (newMode) => {
  const datePicker = document.getElementById("date-picker");
  const locationPicker = document.getElementById("sun-location");
  const timeSlider = document.getElementById("timeContainer");

  datePicker.style.display = "none";
  locationPicker.style.display = "none";
  timeSlider.style.display = "none";

  switch (newMode) {
    case "custom-date":
      datePicker.style.display = "block";
      timeSlider.style.display = "block";
      break;
    case "custom-location":
      locationPicker.style.display = "block";
      break;
  }
});

const [setGraticuleColor] = bindColorPicker("graticule-color", (color) => {
  allGlobes.forEach((g) => (g.graticule.color = color));
  instance.notifyChange(allGlobes);
});

function setLayers(...name) {
  for (const layer of earth.getLayers()) {
    layer.visible = name.includes(layer.name);
  }
}

const [setAmbientIntensity] = bindSlider("ambientIntensity", (intensity) => {
  ambientLight.intensity = intensity;
  instance.notifyChange();
});

const [setSunIntensity] = bindSlider("sunIntensity", (intensity) => {
  sunlight.intensity = intensity;
  instance.notifyChange();
});

const [setGlobe] = bindDropDown("globe-selector", (globe) => {
  allGlobes.forEach((g) => (g.visible = false));

  let entity;

  switch (globe) {
    case "moon":
      moon.visible = true;
      entity = moon;
      break;
    case "sun":
      sun.visible = true;
      entity = sun;
      break;
    case "earth":
      earth.visible = true;
      entity = earth;
      break;
    case "mars":
      mars.visible = true;
      entity = mars;
      break;
  }

  controls?.dispose();

  instance.view.goTo(entity);

  document.getElementById("earth-params").style.display = earth.visible
    ? "block"
    : "none";
  document.getElementById("lightingGroup").style.display = sun.visible
    ? "none"
    : "block";
  document.getElementById("sunParams").style.display = sun.visible
    ? "none"
    : "block";

  earthAtmosphere.visible = earth.visible;
  marsAtmosphere.visible = mars.visible;
  sunGlow.visible = sun.visible;

  instance.notifyChange(entity);

  controls = new GlobeControls({
    scene: entity.object3d,
    ellipsoid: entity.ellipsoid,
    camera: instance.view.camera,
    domElement: instance.domElement,
  });
});

const reset = () => {
  setGlobe("earth"); // TODO
  setLayers("satellite", "clouds");
  setAtmosphere(true);
  setGraticule(false);
  setGraticuleColor(0x000000);
  setSunLatitude(9);
  setSunLongitude(-41);
  setAmbientIntensity(0.4);
  setSunIntensity(4);
  setLighting(true);
  setSunPositionMode("custom-location");

  instance.view.camera.position.copy(defaultCameraPosition);
  instance.view.camera.lookAt(new Vector3(0, 0, 0));

  populateLayerList();
};

bindButton("reset", reset);

function populateLayerList() {
  const list = document.getElementById("layer-list");
  list.innerHTML = "";

  const entries = [
    `<li class="list-group-item list-group-item-secondary">Layers</li>`,
  ];

  const createEntry = (name, visible) => {
    const entry = `
            <li class="list-group-item">
                <input id="layer-${name}" class="form-check-input me-1" ${visible ? "checked" : ""} type="checkbox" />
                <label class="form-check-label" for="layer-${name}">${name}</label>
            </li>
        `;

    entries.push(entry);
  };

  for (const layer of earth.getColorLayers().reverse()) {
    createEntry(layer.name, layer.visible);
  }

  for (const layer of earth.getElevationLayers()) {
    createEntry(layer.name, layer.visible);
  }

  list.innerHTML = entries.join("\n");

  for (const layer of earth.getLayers()) {
    bindToggle(`layer-${layer.name}`, (visible) => {
      layer.visible = visible;
      instance.notifyChange(earth);
    });
  }
}

reset();

const inspector = Inspector.attach("inspector", instance);

inspector.addPanel(
  new GlobeControlsInspector(inspector.gui, instance, controls),
);
index.html
<!doctype html>
<html lang="en">
  <head>
    <title>Globe</title>
    <meta charset="UTF-8" />
    <meta name="name" content="globe" />
    <meta name="description" content="Display a globe-shaped Map." />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <link rel="icon" href="https://giro3d.org/images/favicon.svg" />
    <link
      href="https://giro3d.org/assets/bootstrap-custom.css"
      rel="stylesheet"
    />
    <script src="https://giro3d.org/assets/bootstrap.bundle.min.js"></script>
    <link
      rel="stylesheet"
      type="text/css"
      href="https://giro3d.org/latest/examples/css/example.css"
    />
  </head>

  <body>
    <div id="view" class="m-0 p-0 w-100 h-100"></div>
    <div
      id="inspector"
      class="position-absolute top-0 start-0 mh-100 overflow-auto"
    ></div>

    <div class="side-pane-with-status-bar" style="width: 20rem">
      <!--Parameters -->
      <div class="card">
        <div class="card-header">
          Parameters
          <button
            type="button"
            id="reset"
            class="btn btn-sm btn-primary rounded float-end"
          >
            reset
          </button>
        </div>

        <div class="card-body" id="top-options">
          <!-- Earth/Moon selector -->
          <div class="input-group mb-3">
            <label class="input-group-text" for="globe-selector">Globe</label>
            <select class="form-select" id="globe-selector" autocomplete="off">
              <option value="earth" selected>Earth</option>
              <option value="moon">Moon</option>
              <option value="mars">Mars</option>
              <option value="sun">Sun</option>
            </select>
          </div>

          <div id="earth-params">
            <ul class="list-group mb-3" id="layer-list">
              <!-- Content of this list is generated by the example code -->
              <li class="list-group-item">
                <input class="form-check-input me-1" type="checkbox" />
                <label class="form-check-label" for="firstCheckbox"
                  >Layer 1</label
                >
              </li>
            </ul>
          </div>

          <!-- Atmosphere -->
          <div class="form-check form-switch mb-1">
            <input
              class="form-check-input"
              type="checkbox"
              role="switch"
              id="atmosphere"
              autocomplete="off"
            />
            <label class="form-check-label" for="atmosphere">Atmosphere</label>
          </div>

          <!-- Toggle graticule -->
          <div class="form-check form-switch mb-1">
            <input
              class="form-check-input"
              type="checkbox"
              role="switch"
              id="graticule"
              autocomplete="off"
            />
            <label class="form-check-label w-100" for="graticule">
              <label class="form-check-label w-100" for="graticule">
                <div class="row">
                  <div class="col">Graticule</div>
                  <div class="col-auto">
                    <input
                      type="color"
                      style="height: 1.5rem"
                      class="form-control form-control-color float-end"
                      id="graticule-color"
                      value="#000000"
                      title="Graticule color"
                    />
                  </div>
                </div> </label
            ></label>
          </div>

          <div id="lightingGroup">
            <!-- Toggle lighting -->
            <div class="form-check form-switch mb-1">
              <input
                class="form-check-input"
                type="checkbox"
                role="switch"
                id="lighting"
                autocomplete="off"
              />
              <label class="form-check-label" for="lighting">Lighting</label>
            </div>

            <div id="lightingParams">
              <!-- Sun intensity -->
              <div class="row">
                <div class="col">
                  <label
                    id="sunIntensityLabel"
                    for="sunIntensity"
                    class="form-label"
                    >Sun intensity</label
                  >
                </div>
                <div class="col">
                  <input
                    type="range"
                    min="0"
                    max="10"
                    step="0.1"
                    value="4"
                    class="form-range"
                    id="sunIntensity"
                    autocomplete="off"
                  />
                </div>
              </div>

              <!-- Ambient intensity -->
              <div class="row">
                <div class="col">
                  <label
                    id="ambientIntensityLabel"
                    for="ambientIntensity"
                    class="form-label"
                    >Ambient light intensity</label
                  >
                </div>
                <div class="col">
                  <input
                    type="range"
                    min="0"
                    max="3"
                    step="0.1"
                    value="0.3"
                    class="form-range"
                    id="ambientIntensity"
                    autocomplete="off"
                  />
                </div>
              </div>
            </div>
          </div>

          <div id="sunParams">
            <hr />
            <!-- Sun position mode -->
            <div class="input-group">
              <label class="input-group-text" for="sun-position-mode"
                >Sun position</label
              >
              <select
                class="form-select"
                id="sun-position-mode"
                autocomplete="off"
              >
                <option value="custom-location" selected>By location</option>
                <option value="custom-date">By date</option>
              </select>
            </div>

            <!-- Date -->
            <div id="date-picker">
              <div class="input-group mt-3">
                <label class="input-group-text" for="date">Date</label>
                <input
                  class="form-control"
                  type="date"
                  id="date"
                  autocomplete="off"
                />
                <div class="input-group-text">
                  <button class="btn btn-sm btn-primary" id="now">Now</button>
                </div>
              </div>
            </div>

            <div id="sun-location" class="mt-3">
              <!-- Sun latitude slider -->
              <div class="row 1">
                <div class="col">
                  <label
                    id="sunLatitudeLabel"
                    for="sunLatitude"
                    class="form-label"
                    >Lat: 35° N</label
                  >
                </div>
                <div class="col">
                  <input
                    type="range"
                    min="-90"
                    max="90"
                    step="1"
                    value="35"
                    class="form-range"
                    id="sunLatitude"
                    autocomplete="off"
                  />
                </div>
              </div>

              <!-- Sun longitude -->
              <div class="row">
                <div class="col">
                  <label
                    id="sunLongitudeLabel"
                    for="sunLongitude"
                    class="form-label"
                    >Lat: 35° N</label
                  >
                </div>
                <div class="col">
                  <input
                    type="range"
                    min="-180"
                    max="180"
                    step="1"
                    value="9"
                    class="form-range"
                    id="sunLongitude"
                    autocomplete="off"
                  />
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <div
      class="bg-body border"
      id="timeContainer"
      style="
        display: none;
        position: absolute;
        left: 0;
        bottom: 1.3rem;
        width: 100%;
        height: 7rem;
        padding: 1rem;
      "
    >
      <!-- Background opacity slider -->
      <label for="time" class="form-label"
        ><span id="timeLabel" class="badge rounded-pill text-bg-primary"
          >12:00 UTC</span
        >
      </label>
      <div class="input-group">
        <input
          type="range"
          min="0"
          max="86400"
          step="60"
          value="43200"
          class="form-range"
          id="time"
          autocomplete="off"
        />
      </div>

      <div class="row">
        <div class="col text-start">
          <span id="timeLabel" class="badge rounded-pill text-bg-secondary"
            >00:00</span
          >
        </div>
        <div class="col text-center">
          <span id="timeLabel" class="badge rounded-pill text-bg-secondary"
            >12:00</span
          >
        </div>
        <div class="col text-end">
          <span id="timeLabel" class="badge rounded-pill text-bg-secondary"
            >24:00</span
          >
        </div>
      </div>
    </div>

    <script type="module" src="index.js"></script>
    <script>
      /* activate popovers */
      const popoverTriggerList = [].slice.call(
        document.querySelectorAll('[data-bs-toggle="popover"]'),
      );
      popoverTriggerList.map(
        // bootstrap is used as script in the template, disable warning about undef
        // eslint-disable-next-line no-undef
        (popoverTriggerEl) =>
          new bootstrap.Popover(popoverTriggerEl, {
            trigger: "hover",
            placement: "left",
            content: document.getElementById(
              popoverTriggerEl.getAttribute("data-bs-content"),
            ).innerHTML,
            html: true,
          }),
      );
    </script>
  </body>
</html>
package.json
{
    "name": "globe",
    "dependencies": {
        "colormap": "^2.3.2",
        "@giro3d/giro3d": "0.43.1"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}