Use colormaps to emphasize elevation and terrain features.

Giro3D version
THREE.js version
OpenLayers version
CRS
Memory usage (CPU)
Memory usage (GPU)
Frames
Clear color
Clear alpha
Status
Local clipping enabled
Capabilities
WebGL 2
Max texture units
Max texture size
Precision
Max fragment shader uniforms
Logarithmic depth buffer
Max shader attributes
Check shader errors
EXT_clip_control
EXT_color_buffer_float
EXT_color_buffer_half_float
EXT_conservative_depth
EXT_depth_clamp
EXT_float_blend
EXT_polygon_offset_clamp
EXT_texture_compression_bptc
EXT_texture_compression_rgtc
EXT_texture_filter_anisotropic
EXT_texture_mirror_clamp_to_edge
EXT_texture_norm16
NV_shader_noperspective_interpolation
OES_draw_buffers_indexed
OES_sample_variables
OES_shader_multisample_interpolation
OES_texture_float_linear
OVR_multiview2
WEBGL_clip_cull_distance
WEBGL_compressed_texture_astc
WEBGL_compressed_texture_etc
WEBGL_compressed_texture_etc1
WEBGL_compressed_texture_s3tc
WEBGL_compressed_texture_s3tc_srgb
WEBGL_debug_renderer_info
WEBGL_debug_shaders
WEBGL_lose_context
WEBGL_multi_draw
WEBGL_polygon_mode
WEBGL_stencil_texturing
MSAA
EDL
EDL Radius
EDL Strength
Inpainting
Inpainting steps
Inpainting depth contrib.
Point cloud occlusion
Type
FOV
Automatic plane computation
Far plane
Near plane
Max far plane
Min near plane
Width (pixels)
Height (pixels)
x
y
z
x
y
z
color
Enable cache
Default TTL (seconds)
Capacity (MB)
Capacity (entries)
Entries
Memory usage (approx)
Pending requests
Memory tracker
Identifier
Memory usage (CPU)
Memory usage (GPU)
Status
Render order
Enable
Plane normal X
Plane normal Y
Plane normal Z
Distance
Helper size
Negate plane
Visible
Freeze updates
Opacity
Show volumes
Volume color
Discard no-data values
Sidedness
DoubleSide
Depth test
Visible tiles
Reachable tiles
Loaded tiles
Cast shadow
Receive shadow
Tile width (pixels)
Tile height (pixels)
Show grid
Background
Background opacity
Show tiles outlines
Tile outline color
Show tile info
Show extent
Extent color
Subdivision threshold
Deformation
Wireframe
Tile subdivisions
Show collider meshes
CPU terrain
Stitching
Geometry pool
Enabled
Mode
Hillshade
Hillshade intensity
Z factor
Hillshade zenith
Hillshade azimuth
Elevation layers only
Enable
Color
Opacity
X step
Y step
X Offset
Y Offset
Thickness
Enable
Color
Thickness
Opacity
Primary interval (m)
Secondary interval (m)
Brightness
Contrast
Saturation
Layer count
Render state
Normal
Layers
Identifier
Memory usage (CPU)
Memory usage (GPU)
Name
Source CRS
Status
Resolution factor
Visible
Frozen
Interpretation
Loaded images
Minimum elevation
Maximum elevation
Show extent
Extent color
Enabled
Mode
Elevation
Lower bound
Upper bound
Type
Color space
Data type
Flip Y
Synchronous
CRS
Memory usage (CPU)
Memory usage (GPU)
Loaded/Requested
CRS
Zoom levels
Main URL
Inner source
Show helpers
Show hidden objects
Name filter
Hierarchy
Properties
isObject3D
uuid
name
type
matrixAutoUpdate
matrixWorldAutoUpdate
matrixWorldNeedsUpdate
visible
castShadow
receiveShadow
frustumCulled
renderOrder
x
y
z
x
y
z
Parameters
0% © Mapbox

Colormaps are useful to colorize an elevation dataset. You can change the color map's properties dynamically, such as the min and max values, the color gradients, or disable the color map entirely. Color maps can be applied on both elevation layers and color layers.

index.js
import colormap from "colormap";

import { Vector3, Color, DoubleSide } from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js";

import XYZ from "ol/source/XYZ.js";

import * as FunctionCurveEditor from "function-curve-editor";

import Extent from "@giro3d/giro3d/core/geographic/Extent.js";
import Instance from "@giro3d/giro3d/core/Instance.js";
import ElevationLayer from "@giro3d/giro3d/core/layer/ElevationLayer.js";
import MapboxTerrainFormat from "@giro3d/giro3d/formats/MapboxTerrainFormat.js";
import Map from "@giro3d/giro3d/entities/Map.js";
import Inspector from "@giro3d/giro3d/gui/Inspector.js";
import ColorMap from "@giro3d/giro3d/core/ColorMap.js";
import ColorMapMode from "@giro3d/giro3d/core/ColorMapMode.js";
import TiledImageSource from "@giro3d/giro3d/sources/TiledImageSource.js";
import ColorLayer from "@giro3d/giro3d/core/layer/ColorLayer.js";

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 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 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 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 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 bindColorMapBounds(callback) {
  const min = document.getElementById("min");
  if (!(min instanceof HTMLInputElement)) {
    throw new Error(
      "invalid binding element: expected HTMLInputElement, got: " +
        min.constructor.name,
    );
  }

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

  const lower = min;
  const upper = max;

  callback(lower.valueAsNumber, upper.valueAsNumber);

  function updateLabels() {
    document.getElementById("minLabel").innerText =
      `Lower bound: ${Math.round(lower.valueAsNumber)}m`;
    document.getElementById("maxLabel").innerText =
      `Upper bound: ${Math.round(upper.valueAsNumber)}m`;
  }

  lower.oninput = function oninput() {
    const rawValue = lower.valueAsNumber;
    const clampedValue = MathUtils.clamp(
      rawValue,
      Number.parseFloat(lower.min),
      upper.valueAsNumber - 1,
    );
    lower.valueAsNumber = clampedValue;
    callback(lower.valueAsNumber, upper.valueAsNumber);
    updateLabels();
  };

  upper.oninput = function oninput() {
    const rawValue = upper.valueAsNumber;
    const clampedValue = MathUtils.clamp(
      rawValue,
      lower.valueAsNumber + 1,
      Number.parseFloat(upper.max),
    );
    upper.valueAsNumber = clampedValue;
    callback(lower.valueAsNumber, upper.valueAsNumber);
    updateLabels();
  };

  const externalInput = (min, max) => {
    lower.min = min;
    lower.max = max;
    upper.min = min;
    upper.max = max;
    lower.valueAsNumber = min;
    upper.valueAsNumber = max;
    callback(lower.valueAsNumber, upper.valueAsNumber);
    updateLabels();
  };

  return externalInput;
}

const extent = Extent.fromCenterAndSize(
  "EPSG:3857",
  { x: 697313, y: 5591324 },
  30000,
  30000,
);

const instance = new Instance({
  target: "view",
  crs: extent.crs,
  backgroundColor: null, // To make the canvas transparent
});

const cameraPosition = new Vector3(697119, 5543639, 53043);

instance.view.camera.position.copy(cameraPosition);

const controls = new MapControls(instance.view.camera, instance.domElement);
controls.target = extent.centerAsVector3();
controls.enableDamping = true;
controls.dampingFactor = 0.2;
controls.saveState();
instance.view.setControls(controls);

const elevationMin = 780;
const elevationMax = 3574;

let parameters = {
  ramp: "viridis",
  discrete: false,
  invert: false,
  mirror: false,
  backgroundOpacity: 1,
  transparencyCurveKnots: [
    { x: 0, y: 1 },
    { x: 1, y: 1 },
  ],
  enableColorMap: true,
  layerType: "elevation",
  colors: makeColorRamp("viridis", false, false, false),
  min: elevationMin,
  max: elevationMax,
  mode: ColorMapMode.Elevation,
};

function updatePreview(colors) {
  const canvas = document.getElementById("gradient");
  const ctx = canvas.getContext("2d");

  canvas.width = colors.length;
  canvas.height = 1;

  for (let i = 0; i < colors.length; i++) {
    const color = colors[i];
    ctx.fillStyle = `#${color.getHexString()}`;
    ctx.fillRect(i, 0, 1, canvas.height);
  }
}

updatePreview(parameters.colors);

// Adds the map that will contain the layers.
const map = new Map({
  extent,
  backgroundColor: "cyan",
  side: DoubleSide,
  lighting: {
    enabled: true,
    elevationLayersOnly: true,
  },
});
instance.add(map);

const key =
  "pk.eyJ1IjoidG11Z3VldCIsImEiOiJjbGJ4dTNkOW0wYWx4M25ybWZ5YnpicHV6In0.KhDJ7W5N3d1z3ArrsDjX_A";
const source = new TiledImageSource({
  format: new MapboxTerrainFormat(),
  source: new XYZ({
    url: `https://api.mapbox.com/v4/mapbox.terrain-rgb/{z}/{x}/{y}.pngraw?access_token=${key}`,
    projection: extent.crs,
    crossOrigin: "anonymous",
  }),
});

const backgroundLayer = new ColorLayer({
  name: "background",
  extent,
  source: new TiledImageSource({
    source: new XYZ({
      url: `https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.webp?access_token=${key}`,
      projection: extent.crs,
      crossOrigin: "anonymous",
    }),
  }),
});

const elevationLayer = new ElevationLayer({
  name: "elevation",
  extent,
  source,
  colorMap: new ColorMap({
    colors: parameters.colors,
    min: elevationMin,
    max: elevationMax,
    mode: ColorMapMode.Elevation,
  }),
});

const colorLayer = new ColorLayer({
  name: "color",
  extent,
  source,
  colorMap: new ColorMap({
    colors: parameters.colors,
    min: elevationMin,
    max: elevationMax,
    mode: ColorMapMode.Elevation,
  }),
});

map.addLayer(elevationLayer);

let activeLayer = elevationLayer;

function updateColorRamp() {
  parameters.colors = makeColorRamp(
    parameters.ramp,
    parameters.discrete,
    parameters.invert,
    parameters.mirror,
  );
  activeLayer.colorMap.colors = parameters.colors;
  activeLayer.colorMap.min = parameters.min;
  activeLayer.colorMap.max = parameters.max;
  activeLayer.colorMap.mode = parameters.mode;

  updateTransparency();

  updatePreview(parameters.colors);

  instance.notifyChange(map);
}

const [setEnableColorMap] = bindToggle("enable", (v) => {
  elevationLayer.visible = true;
  colorLayer.visible = true;
  backgroundLayer.visible = true;

  if (activeLayer.type === "ColorLayer") {
    activeLayer.visible = v;
  } else {
    activeLayer.colorMap.active = v;
  }
  instance.notifyChange(map);
});
const [setDiscrete] = bindToggle("discrete", (v) => {
  parameters.discrete = v;
  updateColorRamp();
});
const [setInvert] = bindToggle("invert", (v) => {
  parameters.invert = v;
  updateColorRamp();
});
const [setMirror] = bindToggle("mirror", (v) => {
  parameters.mirror = v;
  updateColorRamp();
});
const [setRamp] = bindDropDown("ramp", (v) => {
  parameters.ramp = v;
  updateColorRamp();
});
function setActiveLayers(...layers) {
  map.removeLayer(colorLayer);
  map.removeLayer(elevationLayer);
  map.removeLayer(backgroundLayer);

  for (const layer of layers) {
    map.addLayer(layer);
  }
  activeLayer = layers[layers.length - 1];
}
const [setLayerType] = bindDropDown("layerType", (v) => {
  switch (v) {
    case "elevation":
      setActiveLayers(elevationLayer);
      break;
    case "color":
      setActiveLayers(colorLayer);
      break;
    case "color+background":
      setActiveLayers(backgroundLayer, colorLayer);
      break;
    case "color+background+elevation":
      setActiveLayers(elevationLayer, backgroundLayer, colorLayer);
      break;
  }
  updateColorRamp();
  instance.notifyChange(map);
});
const [setBackgroundOpacity] = bindSlider("backgroundOpacity", (v) => {
  map.backgroundOpacity = v;
  instance.notifyChange(map);
});
const updateBounds = bindColorMapBounds((min, max) => {
  parameters.min = min;
  parameters.max = max;
  activeLayer.colorMap.min = min;
  activeLayer.colorMap.max = max;
  instance.notifyChange(map);
});

const [setMode] = bindDropDown("mode", (v) => {
  const numerical = Number.parseInt(v);
  switch (numerical) {
    case ColorMapMode.Elevation:
      parameters.mode = ColorMapMode.Elevation;
      updateBounds(elevationMin, elevationMax);
      break;
    case ColorMapMode.Slope:
      parameters.mode = ColorMapMode.Slope;
      updateBounds(0, 90);
      break;
    case ColorMapMode.Aspect:
      parameters.mode = ColorMapMode.Aspect;
      updateBounds(0, 360);
      break;
  }

  updateColorRamp();
  instance.notifyChange(map);
});

const canvas = document.getElementById("curve");

const widget = new FunctionCurveEditor.Widget(canvas);

function updateTransparency() {
  const length = parameters.colors.length;
  const f = widget.getFunction();
  const opacities = new Array(length);
  for (let i = 0; i < length; i++) {
    const t = i / length;
    opacities[i] = f(t);
  }
  activeLayer.colorMap.opacity = opacities;
}

function setupTransparencyCurve(knots = undefined) {
  // Curve editor
  const initialKnots = knots ?? [
    { x: 0, y: 1 },
    { x: 1, y: 1 },
  ];

  widget.setEditorState({
    knots: initialKnots,
    xMin: -0.2,
    xMax: 1.2,
    yMin: -0.2,
    yMax: 1.2,
    interpolationMethod: "linear",
    extendedDomain: true,
    relevantXMin: 0,
    relevantXMax: 1,
    gridEnabled: true,
  });

  widget.addEventListener("change", () => {
    updateColorRamp();
  });
}

setupTransparencyCurve();

function applyPreset(preset) {
  parameters = { ...preset };

  setupTransparencyCurve(preset.transparencyCurveKnots);
  setBackgroundOpacity(preset.backgroundOpacity);
  setRamp(preset.ramp);
  setEnableColorMap(preset.enableColorMap);
  setDiscrete(preset.discrete);
  setInvert(preset.invert);
  setMirror(preset.mirror);
  setMode(preset.mode);
  setLayerType(preset.layerType);
  updateBounds(preset.min, preset.max);
  updateColorRamp();

  instance.notifyChange(map);
}

const [setPreset] = bindDropDown("preset", (preset) => {
  switch (preset) {
    case "elevation":
      applyPreset({
        ramp: "viridis",
        transparencyCurveKnots: [
          { x: 0, y: 1 },
          { x: 1, y: 1 },
        ],
        backgroundOpacity: 1,
        enableColorMap: true,
        discrete: false,
        mirror: false,
        invert: false,
        layerType: "elevation",
        colors: makeColorRamp("viridis", false, false, false),
        opacity: new Array(256).fill(1),
        min: elevationMin,
        max: elevationMax,
        mode: ColorMapMode.Elevation,
      });
      break;

    case "elevation+transparency":
      applyPreset({
        ramp: "jet",
        transparencyCurveKnots: [
          { x: 0, y: 0.5 },
          { x: 0.4, y: 0.5 },
          { x: 0.401, y: 0 },
          { x: 1, y: 0 },
        ],
        backgroundOpacity: 1,
        enableColorMap: true,
        discrete: false,
        mirror: false,
        invert: false,
        layerType: "color+background+elevation",
        colors: makeColorRamp("jet", false, false, false),
        min: elevationMin,
        max: elevationMax,
        mode: ColorMapMode.Elevation,
      });
      break;

    case "southern-slope":
      applyPreset({
        ramp: "rdbu",
        transparencyCurveKnots: [
          { x: 0, y: 0 },
          { x: 0.4, y: 0 },
          { x: 0.401, y: 1 },
          { x: 0.6, y: 1 },
          { x: 0.601, y: 0 },
          { x: 1, y: 0 },
        ],
        backgroundOpacity: 1,
        enableColorMap: true,
        discrete: false,
        mirror: true,
        invert: false,
        layerType: "color+background+elevation",
        colors: makeColorRamp("rdbu", false, false, false),
        min: 0,
        max: 360,
        mode: ColorMapMode.Aspect,
      });
      break;

    case "flat-terrain":
      applyPreset({
        ramp: "jet",
        transparencyCurveKnots: [
          { x: 0, y: 1 },
          { x: 0.3, y: 1 },
          { x: 0.6, y: 0 },
          { x: 1, y: 0 },
        ],
        backgroundOpacity: 1,
        enableColorMap: true,
        discrete: false,
        mirror: false,
        invert: true,
        layerType: "color+background+elevation",
        colors: makeColorRamp("jet", false, false, false),
        min: 0,
        max: 35,
        mode: ColorMapMode.Slope,
      });
      break;
  }
});

function resetToDefaults() {
  setPreset("elevation");
}

bindButton("reset", resetToDefaults);

Inspector.attach("inspector", instance);

// For some reason, not waiting a bit causes the curve editor to be blank on Firefox
setTimeout(resetToDefaults, 100);
index.html
<!doctype html>
<html lang="en">
  <head>
    <title>Color maps</title>
    <meta charset="UTF-8" />
    <meta name="name" content="colormaps" />
    <meta
      name="description"
      content="Use colormaps to emphasize elevation and terrain features."
    />
    <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"
    />

    <style>
      #view canvas {
        background: rgb(132, 170, 182);
        background: radial-gradient(
          circle,
          rgba(132, 170, 182, 1) 0%,
          rgba(37, 44, 48, 1) 100%
        );
      }
    </style>
  </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">
          <!-- Preset -->
          <div class="input-group mb-3">
            <label class="input-group-text" for="preset">Preset</label>
            <select class="form-select" id="preset" autocomplete="off">
              <option value="elevation" selected>
                Simple elevation layer with color map
              </option>
              <option value="elevation+transparency">
                Elevation layer with color map visible only on the 780-2000m
                range.
              </option>
              <option value="southern-slope">
                Show the southern slopes of the mountains with a
                semi-transparent colormap.
              </option>
              <option value="flat-terrain">
                Shows flat areas of the terrain.
              </option>
            </select>
          </div>

          <!-- Layer type -->
          <div class="input-group mb-3">
            <label class="input-group-text" for="layerType">Layers</label>
            <select class="form-select" id="layerType" autocomplete="off">
              <option value="elevation" selected>Elevation</option>
              <option value="color">Color</option>
              <option value="color+background">Color + Background</option>
              <option value="color+background+elevation">
                Color + Background + Elevation
              </option>
            </select>
          </div>

          <!-- Activate color map -->
          <div class="form-check form-switch mb-1">
            <input
              class="form-check-input"
              checked
              type="checkbox"
              role="switch"
              id="enable"
              autocomplete="off"
            />
            <label class="form-check-label" for="enable"
              >Enable color map</label
            >
          </div>

          <!-- Reverse color map -->
          <div class="form-check form-switch mb-1">
            <input
              class="form-check-input"
              type="checkbox"
              role="switch"
              id="invert"
              autocomplete="off"
            />
            <label class="form-check-label" for="invert"
              >Invert color map</label
            >
          </div>

          <!-- Mirror color map -->
          <div class="form-check form-switch mb-1">
            <input
              class="form-check-input"
              type="checkbox"
              role="switch"
              id="mirror"
              autocomplete="off"
            />
            <label class="form-check-label" for="mirror"
              >Mirror color map</label
            >
          </div>

          <!-- Discrete color map -->
          <div class="form-check form-switch mb-3">
            <input
              class="form-check-input"
              type="checkbox"
              role="switch"
              id="discrete"
              autocomplete="off"
            />
            <label class="form-check-label" for="discrete"
              >Discrete color map</label
            >
          </div>

          <!-- Color ramp selector -->
          <div class="input-group mb-3">
            <label class="input-group-text" for="ramp">Colors</label>
            <select class="form-select" id="ramp" autocomplete="off">
              <option value="viridis" selected>Viridis</option>
              <option value="jet">Jet</option>
              <option value="greys">Greys</option>
              <option value="blackbody">Blackbody</option>
              <option value="earth">Earth</option>
              <option value="bathymetry">Bathymetry</option>
              <option value="magma">Magma</option>
              <option value="par">Par</option>
              <option value="rdbu">RdBu</option>
            </select>
          </div>

          <!-- Gradient preview -->
          <div class="mb-3 w-100">
            <canvas
              id="gradient"
              height="32"
              class="w-100 border rounded"
              style="height: 32px; image-rendering: pixelated"
            ></canvas>
          </div>

          <!-- Opacity curve -->
          <div class="mb-3 w-100">
            <label for="curve" class="mb-2">Opacity curve</label>
            <canvas
              id="curve"
              height="128"
              class="w-100"
              style="height: 128px"
            ></canvas>
          </div>

          <!-- Mode selector -->
          <div class="input-group mb-3">
            <label class="input-group-text" for="mode">Mode</label>
            <select class="form-select" id="mode" autocomplete="off">
              <option value="1" selected>Elevation</option>
              <option value="2">Slope</option>
              <option value="3">Aspect</option>
            </select>
          </div>

          <!-- Background opacity slider -->
          <div class="input-group border rounded p-2 mb-3">
            <label
              for="backgroundOpacity"
              id="backgroundOpacityLabel"
              class="form-label"
              >Map background opacity</label
            >
            <div class="input-group">
              <input
                type="range"
                min="0"
                max="1"
                step="0.01"
                value="1"
                class="form-range"
                id="backgroundOpacity"
                autocomplete="off"
              />
            </div>
          </div>

          <!-- Bound sliders -->
          <div class="input-group border rounded p-2" id="bounds">
            <label for="min" id="minLabel" class="form-label"
              >Lower bound: 780m</label
            >
            <div class="input-group">
              <input
                type="range"
                min="780"
                max="3574"
                value="0"
                class="form-range"
                id="min"
                autocomplete="off"
              />
            </div>

            <label for="max" id="maxLabel" class="form-label"
              >Upper bound: 3574m</label
            >
            <div class="input-group">
              <input
                type="range"
                min="780"
                max="3574"
                value="3574"
                class="form-range"
                id="max"
                autocomplete="off"
              />
            </div>
          </div>
        </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": "colormaps",
    "dependencies": {
        "colormap": "^2.3.2",
        "function-curve-editor": "^1.0.16",
        "@giro3d/giro3d": "0.42.4"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}