Display a Cloud optimized point cloud

Loading metadata...
100% Autzen stadium dataset provided by United States Geological Survey and Hobu, Inc.
index.js
import { Color } from "three";

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

import Instance from "@giro3d/giro3d/core/Instance.js";
import Extent from "@giro3d/giro3d/core/geographic/Extent.js";
import ColorLayer from "@giro3d/giro3d/core/layer/ColorLayer.js";
import ColorMap from "@giro3d/giro3d/core/layer/ColorMap.js";
import ElevationLayer from "@giro3d/giro3d/core/layer/ElevationLayer.js";
import Map from "@giro3d/giro3d/entities/Map.js";
import PointCloud from "@giro3d/giro3d/entities/PointCloud.js";
import MapboxTerrainFormat from "@giro3d/giro3d/formats/MapboxTerrainFormat.js";
import Inspector from "@giro3d/giro3d/gui/Inspector.js";
import COPCSource from "@giro3d/giro3d/sources/COPCSource.js";
import TiledImageSource from "@giro3d/giro3d/sources/TiledImageSource.js";
import { setLazPerfPath } from "@giro3d/giro3d/sources/las/config.js";

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 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 bindNumberInput(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(element.valueAsNumber);
  };

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

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

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

  const setProgress = (normalized, text) => {
    element.style.width = `${Math.round(normalized * 100)}%`;
    if (text) {
      element.innerText = text;
    }
  };

  return [setProgress, element.parentElement];
}

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 formatPointCount(count, numberFormat = undefined) {
  let displayedPointCount = count;
  let suffix = "";

  if (count > 1_000_000) {
    displayedPointCount /= 1_000_000;
    suffix = "M";
  } else if (count > 1_000_000_000) {
    displayedPointCount /= 1_000_000_000;
    suffix = "B";
  }

  if (numberFormat == null) {
    numberFormat = new Intl.NumberFormat(undefined, {
      maximumFractionDigits: 2,
    });
  }

  return numberFormat.format(displayedPointCount) + suffix;
}

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 placeCameraOnTop(volume, instance) {
  if (!instance) {
    return;
  }

  const center = volume.getCenter(new Vector3());
  const size = volume.getSize(new Vector3());

  const camera = instance.view.camera;
  const top = volume.max.z;
  const fov = camera.fov;
  const aspect = camera.aspect;

  const hFov = MathUtils.degToRad(fov) / 2;
  const altitude = (Math.max(size.x / aspect, size.y) / Math.tan(hFov)) * 0.5;

  instance.view.camera.position.set(center.x, center.y - 1, altitude + top);
  instance.view.camera.lookAt(center);

  const controls = new MapControls(instance.view.camera, instance.domElement);
  controls.target.copy(center);
  controls.enableDamping = true;
  controls.dampingFactor = 0.25;

  instance.view.setControls(controls);
  instance.notifyChange(instance.view.camera);
}

// LAS processing requires the WebAssembly laz-perf library
// This path is specific to your project, and must be set accordingly.
setLazPerfPath("/assets/wasm");

// We use this CRS when the point cloud does not have a CRS defined.
// It is technically the WebMercator CRS, but we label it 'unknown' to make
// it very explicit that it is not correct.
// See https://gitlab.com/giro3d/giro3d/-/issues/514
Instance.registerCRS(
  "unknown",
  "+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs +type=crs",
);

let instance;

const options = {
  mode: "attribute",
  attribute: "position",
  colorRamp: "bathymetry",
  min: 0,
  max: 100,
  enableFilters: false,
};

let entity;

let colorLayer;

function updateColoring() {
  const attribute = options.attribute;

  if (options.mode === "layer") {
    if (colorLayer != null) {
      entity.setColorLayer(colorLayer);
      entity.setColoringMode("layer");
    }
  } else {
    entity.setColoringMode("attribute");
    entity.setActiveAttribute(attribute);
  }

  const classificationGroup = document.getElementById("classification-group");
  const colorMapGroup = document.getElementById("ramp-group");

  const shouldDisplayClassifications = attribute === "Classification";
  classificationGroup.style.display = shouldDisplayClassifications
    ? "block"
    : "none";
  colorMapGroup.style.display =
    !shouldDisplayClassifications && attribute !== "Color" ? "flex" : "none";

  if (options.mode !== "layer") {
    updateColorMap();
  }
}

const [setProgress, progressElement] = bindProgress("progress");

const [, , , setAvailableAttributes] = bindDropDown(
  "attribute",
  (attribute) => {
    options.attribute = attribute;

    if (entity) {
      updateColoring();
    }
  },
);

const [setMin] = bindSlider("min", (min) => {
  options.min = Math.round(min);
  if (entity && instance) {
    entity.colorMap.min = min;
    instance.notifyChange(entity);
    document.getElementById("label-bounds").innerHTML =
      `Bounds: <b>${options.min}</b> — <b>${options.max}<b>`;
  }
});

const [setMax] = bindSlider("max", (max) => {
  options.max = Math.round(max);
  if (entity && instance) {
    entity.colorMap.max = max;
    instance.notifyChange(entity);
    document.getElementById("label-bounds").innerHTML =
      `Bounds: <b>${options.min}</b> — <b>${options.max}<b>`;
  }
});

bindToggle("show-tile-volumes", (v) => {
  entity.showNodeVolumes = v;
});

bindToggle("show-volume", (v) => {
  entity.showVolume = v;
});

bindToggle("edl", (v) => {
  instance.renderingOptions.enableEDL = v;
  instance.notifyChange();
});

bindToggle("inpainting", (v) => {
  instance.renderingOptions.enableInpainting = v;
  instance.renderingOptions.enablePointCloudOcclusion = v;
  instance.notifyChange();
});

bindSlider("point-size", (size) => {
  if (entity) {
    entity.pointSize = size;
    document.getElementById("point-size-label").innerHTML =
      `Point size: <b>${size === 0 ? "auto" : size.toFixed(0)}</b>`;
  }
});
bindSlider("subdivision-threshold", (threshold) => {
  if (entity) {
    entity.subdivisionThreshold = threshold;
    document.getElementById("subdivision-threshold-label").innerHTML =
      `Subdivision threshold: <b>${threshold}</b>`;
  }
});

function updateColorMapMinMax() {
  if (!entity) {
    return;
  }

  const min = entity.activeAttribute.min ?? 0;
  const max = entity.activeAttribute.max ?? 255;

  const step = entity.activeAttribute.type === "float" ? 0.0001 : 1;

  const lowerBound = min;
  const upperBound = max;

  setMin(min, lowerBound, upperBound, step);
  setMax(max, lowerBound, upperBound, step);
}

bindDropDown("ramp", (ramp) => {
  options.colorRamp = ramp;
  updateColorMap();
});

function updateColorMap() {
  if (entity && instance) {
    entity.colorMap.colors = makeColorRamp(options.colorRamp);

    updateColorMapMinMax();

    instance.notifyChange();
  }
}

function loadMap(instance, extent) {
  const map = new Map({ extent, depthTest: false });

  instance.add(map);

  const key =
    "pk.eyJ1IjoidG11Z3VldCIsImEiOiJjbGJ4dTNkOW0wYWx4M25ybWZ5YnpicHV6In0.KhDJ7W5N3d1z3ArrsDjX_A";

  // Adds a XYZ elevation layer with MapBox terrain RGB tileset
  const elevationLayer = new ElevationLayer({
    extent,
    resolutionFactor: 0.25,
    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: "EPSG:3857",
      }),
    }),
  });
  map.addLayer(elevationLayer);

  // Adds a XYZ color layer with MapBox satellite tileset
  colorLayer = new ColorLayer({
    extent,
    resolutionFactor: 0.5,
    source: new TiledImageSource({
      source: new XYZ({
        url: `https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.webp?access_token=${key}`,
        projection: "EPSG:3857",
      }),
    }),
  });
  map.addLayer(colorLayer);

  return map;
}

async function fetchCrsDefinition(crs) {
  const code = crs.split(":")[1];

  async function fetchText(url) {
    const res = await fetch(url, { mode: "cors" });
    const def = await res.text();
    return def;
  }

  const def = await fetchText(`https://epsg.io/${code}.proj4?download=1`);

  Instance.registerCRS(crs, def);

  return def;
}

const numberFormat = new Intl.NumberFormat(undefined, {
  maximumFractionDigits: 2,
});

function updateDisplayedPointCounts(count, displayed) {
  const pointCountElement = document.getElementById("point-count");
  pointCountElement.innerHTML = formatPointCount(count, numberFormat);
  pointCountElement.title = numberFormat.format(count);

  const activePointCountElement = document.getElementById(
    "displayed-point-count",
  );
  activePointCountElement.innerHTML = formatPointCount(displayed, numberFormat);
  activePointCountElement.title = numberFormat.format(displayed);
}

let filters = [null, null, null];

function updateFilters(source) {
  source.filters = options.enableFilters ? filters : null;
}

function bindFilter(index, attributes, onChange) {
  const filter = {
    dimension: "Z",
    operator: "not",
    value: 0,
  };

  filters[index - 1] = filter;

  const [, , , setFilterAttributes] = bindDropDown(
    `filter-${index}-attribute`,
    (filterAttribute) => {
      filter.dimension = filterAttribute;
      onChange();
    },
  );
  bindDropDown(`filter-${index}-operator`, (filterOperator) => {
    filter.operator = filterOperator;
    onChange();
  });
  bindNumberInput(`filter-${index}-value`, (v) => {
    filter.value = v;
    onChange();
  });

  setFilterAttributes(
    attributes.map((a, i) => {
      return { id: a.name, name: a.name, selected: i === 0 };
    }),
  );
}

function populateGUI(crs, entity, source) {
  document.getElementById("accordion").style.display = "block";

  const tableElement = document.getElementById("table");
  tableElement.style.display = "block";

  const projectionElement = document.getElementById("projection");
  if (crs != null) {
    projectionElement.href = `https://epsg.io/${instance.referenceCrs.split(":")[1]}`;
    projectionElement.innerHTML = instance.referenceCrs;
  } else {
    projectionElement.parentElement.remove();
  }

  progressElement.style.display = "none";

  const attributes = entity.getSupportedAttributes();

  // Bind the 3 filters
  bindFilter(1, attributes, () => updateFilters(source));
  bindFilter(2, attributes, () => updateFilters(source));
  bindFilter(3, attributes, () => updateFilters(source));

  bindToggle("filters", (v) => {
    options.enableFilters = v;
    updateFilters(source);
  });
}

// Loads the point cloud from the url parameter
async function load(url) {
  progressElement.style.display = "block";

  // Let's create the source
  const source = new COPCSource({ url });

  source.addEventListener("progress", () => setProgress(source.progress));

  try {
    // Initialize the source in advance, so that we can
    // access the metadata of the remote LAS file.
    await source.initialize();
  } catch (err) {
    if (err instanceof Error) {
      const messageElement = document.getElementById("message");
      messageElement.innerText = err.message;
      messageElement.style.display = "block";
    }
    progressElement.style.display = "none";
    console.error(err);
    return;
  }

  const metadata = await source.getMetadata();

  instance = new Instance({
    target: "view",
    crs: metadata.crs?.name ?? "unknown",
    backgroundColor: null,
  });

  setAvailableAttributes(
    metadata.attributes.map((att, index) => ({
      id: att.name,
      name: att.name,
      selected: index === 0,
    })),
  );

  options.attribute = metadata.attributes[0].name;

  // Let's enable Eye Dome Lighting to make the point cloud more readable.
  instance.renderingOptions.enableEDL = true;
  instance.renderingOptions.EDLRadius = 0.6;
  instance.renderingOptions.EDLStrength = 5;

  // Let's create our point cloud with the COPC source.
  entity = new PointCloud({ source });

  await instance.add(entity);

  instance.addEventListener("update-end", () =>
    updateDisplayedPointCounts(entity.pointCount, entity.displayedPointCount),
  );

  // Let's get the volume of the point cloud for various operations.
  const volume = entity.getBoundingBox();

  // Create the color map. The color ramp and bounds will be set later.
  entity.colorMap = new ColorMap([], 0, 1);

  // Such as setting the min and max of the colormap bounds.
  setMin(volume.min.z, volume.min.z, volume.max.z);
  setMax(volume.max.z, volume.min.z, volume.max.z);

  updateColoring();
  updateColorMap();

  bindToggle("show-dataset", (show) => {
    entity.visible = show;
    instance.notifyChange(entity);
  });

  bindToggle("radio-layer", (v) => {
    if (v) {
      options.mode = "layer";
      document.getElementById("group-attribute").style.display = "none";

      updateColoring();
    }
  });

  bindToggle("radio-attribute", (v) => {
    if (v) {
      options.mode = "attribute";
      document.getElementById("group-attribute").style.display = "block";

      updateColoring();
    }
  });

  // If the source provides a coordinate system, we can load a map
  // to display as a geographic context and be able to check that the
  // point cloud is properly positioned.
  if (metadata.crs) {
    try {
      await fetchCrsDefinition(metadata.crs.name);

      // We create the extent from the volume of the point cloud.
      const extent = Extent.fromBox3(instance.referenceCrs, volume);
      const map = loadMap(instance, extent.withRelativeMargin(1.2));

      document.getElementById("basemap-group").style.display = "block";
      bindToggle("show-basemap", (show) => {
        map.visible = show;
        instance.notifyChange(map);
      });
    } catch (e) {
      console.warn("could not load map: " + e);
    }
  }

  // Let's populate the classification list with default values from the ASPRS classifications.
  addClassification(0, "Created, never classified", entity.classifications);
  addClassification(1, "Unclassified", entity.classifications);
  addClassification(2, "Ground", entity.classifications);
  addClassification(3, "Low vegetation", entity.classifications);
  addClassification(4, "Medium vegetation", entity.classifications);
  addClassification(5, "High vegetation", entity.classifications);
  addClassification(6, "Building", entity.classifications);
  addClassification(7, "Low point (noise)", entity.classifications);
  addClassification(8, "Reserved", entity.classifications);
  addClassification(9, "Water", entity.classifications);
  addClassification(10, "Rail", entity.classifications);
  addClassification(11, "Road surface", entity.classifications);
  addClassification(12, "Reserved", entity.classifications);
  addClassification(13, "Wire - Guard (shield)", entity.classifications);
  addClassification(14, "Wire - Conductor (Phase)", entity.classifications);
  addClassification(15, "Transmission Tower", entity.classifications);
  addClassification(
    16,
    "Wire Structure connector (e.g Insulator)",
    entity.classifications,
  );
  addClassification(17, "Bridge deck", entity.classifications);
  addClassification(18, "High noise", entity.classifications);

  populateGUI(metadata.crs?.name, entity, source);

  Inspector.attach("inspector", instance);

  placeCameraOnTop(volume, instance);

  instance.notifyChange();
}

const defaultUrl =
  "https://3d.oslandia.com/giro3d/pointclouds/autzen-classified.copc.laz";

// Extract dataset URL from URL
const url = new URL(document.URL);
let datasetUrl = url.searchParams.get("dataset");
if (!datasetUrl) {
  datasetUrl = defaultUrl;
  url.searchParams.append("dataset", datasetUrl);
  window.history.replaceState({}, null, url.toString());
}

const fragments = new URL(datasetUrl).pathname.split("/");
document.getElementById("filename").innerText = fragments[fragments.length - 1];

// GUI controls for classification handling

const classificationNames = new Array(32);

function addClassification(number, name, array) {
  const currentColor = array[number].color.getHexString();

  const template = `
    <div class="form-check">
        <input
            class="form-check-input"
            type="checkbox"
            checked
            role="switch"
            id="class-${number}"
            autocomplete="off"
        />
        <label class="form-check-label w-100" for="class-${number}">
            <div class="row">
                <div class="col" style="font-size: 13px">${name}</div>
                <div class="col-auto">
                    <input
                        type="color"
                        style="height: 1rem; padding: 1px;"
                        class="form-control form-control-color float-end"
                        id="color-${number}"
                        value="#${currentColor}"
                        title="Classification color"
                    />
                </div>
            </div>
        </label>
    </div>
    `;

  const node = document.createElement("div");
  node.innerHTML = template;
  document.getElementById("classifications").appendChild(node);

  // Let's change the classification color with the color picker value
  bindColorPicker(`color-${number}`, (v) => {
    // Parse it into a THREE.js color
    const color = new Color(v);

    array[number].color = color;

    instance.notifyChange();
  });

  classificationNames[number] = name;

  bindToggle(`class-${number}`, (enabled) => {
    // By toggling the .visible property of a classification,
    // all points that have this classification are hidden/shown.
    array[number].visible = enabled;
    instance.notifyChange();
  });
}

load(datasetUrl).catch(console.error);
index.html
<!doctype html>
<html lang="en">
  <head>
    <title>COPC</title>
    <meta charset="UTF-8" />
    <meta name="name" content="copc" />
    <meta name="description" content="Display a Cloud optimized point cloud" />
    <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">
      <div class="progress" role="progressbar">
        <div
          class="progress-bar bg-info progress-bar-striped progress-bar-animated text-dark"
          id="progress"
          style="width: 0%"
        >
          Loading metadata...
        </div>
      </div>

      <!-- Error message -->
      <div
        class="alert alert-danger mt-0 mb-0"
        id="message"
        style="display: none"
        role="alert"
      >
        A simple primary alert—check it out!
      </div>

      <!--Parameters -->
      <div class="card-body">
        <!-- Accordion -->
        <div class="accordion" style="display: none" id="accordion">
          <!-- Section: info -->
          <div class="accordion-item">
            <h2 class="accordion-header">
              <button
                class="accordion-button"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#section-info"
                aria-expanded="true"
                aria-controls="section-info"
              >
                Info
              </button>
            </h2>
            <div
              id="section-info"
              class="accordion-collapse collapse show"
              data-bs-parent="#accordion"
            >
              <ul
                class="list-group list-group-flush"
                id="table"
                style="display: none; font-size: 0.875rem"
              >
                <li class="list-group-item">
                  Filename
                  <b
                    id="filename"
                    class="d-float float-end text-truncate"
                    style="max-width: 70%"
                  ></b>
                </li>
                <li
                  class="list-group-item"
                  title="The total number of points in the dataset"
                >
                  Total points
                  <b id="point-count" class="d-float float-end"></b>
                </li>
                <li
                  class="list-group-item"
                  title="The number of points currently displayed"
                >
                  Displayed points
                  <b id="displayed-point-count" class="d-float float-end"></b>
                </li>
                <li
                  class="list-group-item"
                  title="The coordinate reference system of this dataset"
                >
                  CRS
                  <a
                    target="_blank"
                    href="foo"
                    id="projection"
                    class="d-float float-end"
                  ></a>
                </li>
              </ul>
            </div>
          </div>

          <!-- Section: options -->
          <div class="accordion-item">
            <h2 class="accordion-header">
              <button
                class="accordion-button"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#section-options"
                aria-expanded="false"
                aria-controls="section-options"
              >
                Options
              </button>
            </h2>

            <div
              id="section-options"
              class="accordion-collapse collapse p-2"
              data-bs-parent="#accordion"
            >
              <!-- Show volume -->
              <div class="form-check form-switch">
                <input
                  class="form-check-input"
                  type="checkbox"
                  role="switch"
                  id="show-volume"
                  autocomplete="off"
                />
                <label
                  title="Show the volume of the dataset"
                  class="form-check-label"
                  for="show-volume"
                  >Show dataset volume</label
                >
              </div>

              <!-- Show octree volumes -->
              <div class="form-check form-switch">
                <input
                  class="form-check-input"
                  type="checkbox"
                  role="switch"
                  id="show-tile-volumes"
                  autocomplete="off"
                />
                <label
                  title="Show the volumes of the octree cells"
                  class="form-check-label"
                  for="show-tile-volumes"
                  >Show octree volumes</label
                >
              </div>

              <!-- Show basemap -->
              <div
                class="form-check form-switch"
                style="display: none"
                id="basemap-group"
              >
                <input
                  class="form-check-input"
                  type="checkbox"
                  role="switch"
                  checked
                  id="show-basemap"
                  autocomplete="off"
                />
                <label
                  title="Show the basemap"
                  class="form-check-label"
                  for="show-basemap"
                  >Show basemap</label
                >
              </div>

              <!-- Show cloud -->
              <div class="form-check form-switch">
                <input
                  class="form-check-input"
                  type="checkbox"
                  role="switch"
                  checked
                  id="show-dataset"
                  autocomplete="off"
                />
                <label class="form-check-label" for="show-dataset"
                  >Show dataset</label
                >
              </div>

              <!-- Eye Dome Lighting -->
              <div class="form-check form-switch">
                <input
                  class="form-check-input"
                  type="checkbox"
                  role="switch"
                  checked
                  id="edl"
                  autocomplete="off"
                />
                <label
                  title="Toggles Eye Dome Lighting post-processing effect"
                  class="form-check-label"
                  for="edl"
                  >Eye Dome Lighting</label
                >
              </div>

              <!-- Inpainting -->
              <div class="form-check form-switch">
                <input
                  class="form-check-input"
                  type="checkbox"
                  role="switch"
                  id="inpainting"
                  autocomplete="off"
                />
                <label
                  title="Toggles inpainting"
                  class="form-check-label"
                  for="inpainting"
                  >Inpainting</label
                >
              </div>

              <!-- Point size slider -->
              <label
                for="point-size"
                class="form-label mt-2"
                id="point-size-label"
                >Point size: <b>auto</b></label
              >
              <input
                type="range"
                min="0"
                max="50"
                step="1"
                value="0"
                title="The point size, in pixels"
                class="form-range"
                id="point-size"
                autocomplete="off"
              />

              <!-- Subdivision threshold slider -->
              <label
                for="subdivision-threshold"
                id="subdivision-threshold-label"
                class="form-label"
                >Subdvision threshold: <b>1.0</b></label
              >
              <input
                type="range"
                min="0.1"
                max="3"
                step="0.1"
                value="1"
                title="The subdivision threshold of the point cloud. The lower, the higher the number of points simultaneously displayed."
                class="form-range"
                id="subdivision-threshold"
                autocomplete="off"
              />
            </div>
          </div>

          <!-- Section: filters -->
          <div class="accordion-item">
            <h2 class="accordion-header">
              <button
                class="accordion-button"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#section-filters"
                aria-expanded="false"
                aria-controls="section-filters"
              >
                Filters
              </button>
            </h2>

            <div
              id="section-filters"
              class="accordion-collapse collapse p-2"
              data-bs-parent="#accordion"
            >
              <!-- Toggle filters -->
              <div class="form-check form-switch">
                <input
                  class="form-check-input"
                  type="checkbox"
                  role="switch"
                  id="filters"
                  autocomplete="off"
                />
                <label
                  title="Enable source filters"
                  class="form-check-label"
                  for="filters"
                  >Enable filters</label
                >
              </div>

              <!-- Filter list (filled by javascript code directly) -->
              <ul class="list-group">
                <!-- Filter 1 -->
                <li class="list-group-item p-1">
                  <div class="input-group">
                    <select
                      class="form-select"
                      id="filter-1-attribute"
                      autocomplete="off"
                      placeholder="Dimension"
                      title="The attribute to filter"
                    ></select>
                    <select
                      class="form-control"
                      style="max-width: 3rem"
                      id="filter-1-operator"
                      autocomplete="off"
                      title="The comparison operator"
                    >
                      <option value="not" selected>≠</option>
                      <option value="equal">=</option>
                      <option value="greater">></option>
                      <option value="greaterequal">≥</option>
                      <option value="less"><</option>
                      <option value="lessequal">≤</option>
                    </select>
                    <input
                      type="number"
                      id="filter-1-value"
                      class="form-control"
                      placeholder="Value"
                      aria-label="Value"
                    />
                  </div>
                </li>

                <!-- Filter 2 -->
                <li class="list-group-item p-1">
                  <div class="input-group">
                    <select
                      class="form-select"
                      id="filter-2-attribute"
                      autocomplete="off"
                      placeholder="Dimension"
                      title="The attribute to filter"
                    ></select>
                    <select
                      class="form-control"
                      style="max-width: 3rem"
                      id="filter-2-operator"
                      autocomplete="off"
                      title="The comparison operator"
                    >
                      <option value="not" selected>≠</option>
                      <option value="equal">=</option>
                      <option value="greater">></option>
                      <option value="greaterequal">≥</option>
                      <option value="less"><</option>
                      <option value="lessequal">≤</option>
                    </select>
                    <input
                      type="number"
                      id="filter-2-value"
                      class="form-control"
                      placeholder="Value"
                      aria-label="Value"
                    />
                  </div>
                </li>

                <!-- Filter 3 -->
                <li class="list-group-item p-1">
                  <div class="input-group">
                    <select
                      class="form-select"
                      id="filter-3-attribute"
                      autocomplete="off"
                      placeholder="Dimension"
                      title="The attribute to filter"
                    ></select>
                    <select
                      class="form-control"
                      style="max-width: 3rem"
                      id="filter-3-operator"
                      autocomplete="off"
                      title="The comparison operator"
                    >
                      <option value="not" selected>≠</option>
                      <option value="equal">=</option>
                      <option value="greater">></option>
                      <option value="greaterequal">≥</option>
                      <option value="less"><</option>
                      <option value="lessequal">≤</option>
                    </select>
                    <input
                      type="number"
                      id="filter-3-value"
                      class="form-control"
                      placeholder="Value"
                      aria-label="Value"
                    />
                  </div>
                </li>
              </ul>
            </div>
          </div>

          <!-- Section: coloring -->
          <div class="accordion-item">
            <h2 class="accordion-header">
              <button
                class="accordion-button"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#section-coloring"
                aria-expanded="false"
                aria-controls="section-coloring"
              >
                Coloring
              </button>
            </h2>

            <div
              id="section-coloring"
              class="accordion-collapse collapse p-2"
              data-bs-parent="#accordion"
            >
              <div class="form-check">
                <input
                  class="form-check-input"
                  type="radio"
                  name="radio-group-coloring"
                  id="radio-layer"
                />
                <label class="form-check-label" for="radio-layer">
                  From color layer
                </label>
              </div>
              <div class="form-check">
                <input
                  class="form-check-input"
                  type="radio"
                  name="radio-group-coloring"
                  id="radio-attribute"
                  checked
                />
                <label class="form-check-label" for="radio-attribute">
                  From attribute
                </label>
              </div>

              <div class="mt-2" id="group-attribute">
                <!-- Active attribute selector -->
                <div class="input-group mt-1" id="attribute-group">
                  <label class="input-group-text col-5" for="attribute"
                    >Attribute</label
                  >
                  <select
                    class="form-select"
                    id="attribute"
                    autocomplete="off"
                    title="Sets the active attribute of the point cloud"
                  ></select>
                </div>

                <!-- Color ramp selector -->
                <div id="ramp-group" class="input-group mt-2">
                  <label class="input-group-text col-5" for="ramp"
                    >Color ramp</label
                  >
                  <select class="form-select" id="ramp" autocomplete="off">
                    <option value="viridis">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" selected>Bathymetry</option>
                    <option value="magma">Magma</option>
                    <option value="par">Par</option>
                    <option value="rdbu">RdBu</option>
                  </select>

                  <!-- Bound sliders -->
                  <div class="input-group border rounded p-2 mt-2" id="bounds">
                    <label
                      for="min"
                      id="label-bounds"
                      class="form-label"
                      style="font-size: 0.8rem"
                      >Bounds: 123 - 456</label
                    >
                    <div class="input-group">
                      <input
                        type="range"
                        min="780"
                        max="3574"
                        value="0"
                        class="form-range"
                        id="min"
                        autocomplete="off"
                      />
                    </div>

                    <div class="input-group">
                      <input
                        type="range"
                        min="780"
                        max="3574"
                        value="3574"
                        class="form-range"
                        id="max"
                        autocomplete="off"
                      />
                    </div>
                  </div>
                </div>

                <!-- Classification list -->
                <div id="classification-group" class="mt-2">
                  <fieldset id="classifications" class="border rounded p-2">
                    <!-- Classifications are added dynamically from the JS example -->
                  </fieldset>
                </div>
              </div>
            </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": "copc",
    "dependencies": {
        "@giro3d/giro3d": "0.41.0"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}