Display a Cloud optimized point cloud
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);
<!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/next/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>
{
"name": "copc",
"dependencies": {
"@giro3d/giro3d": "git+https://gitlab.com/giro3d/giro3d.git"
},
"devDependencies": {
"vite": "^3.2.3"
},
"scripts": {
"start": "vite",
"build": "vite build"
}
}