Create an elevation profile using a Map and a path.

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)
Status
Render order
Enable
Plane normal X
Plane normal Y
Plane normal Z
Distance
Helper size
Negate plane
Visible
Freeze updates
Opacity
Color
Segment labels
Line label
Surface label
Vertical line labels
Vertex labels
Surface
Surface opacity
Label opacity
Vertices
Floor vertices
Line
Floor line
Vertical lines
Floor elevation
Dashed
Dash size
Depth test
Font size (px)
Font weight
bold
Line width
Vertex radius
Border width
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
Color
Segment labels
Line label
Surface label
Vertical line labels
Vertex labels
Surface
Surface opacity
Label opacity
Vertices
Floor vertices
Line
Floor line
Vertical lines
Floor elevation
Dashed
Dash size
Depth test
Font size (px)
Font weight
bold
Line width
Vertex radius
Border width
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
Options
?

Clik on the Create profile button to draw a path on the map. Use right-click to complete the drawing.

samples
100% © IGN

Use the getElevation() method to sample the elevation at a given coordinate. The elevation profile chart is built with Chart.js. Keep in mind, however, that the accuracy of the profile will depend on the currently loaded data, which in turns depend on the position of the camera. The closer the camer is to a certain point, the better the resolution will be around this point.

index.js
import colormap from "colormap";

import { CurvePath, DoubleSide, LineCurve, Vector2, Vector3 } from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js";
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";

import * as ChartJS from "chart.js";

import DrawTool from "@giro3d/giro3d/interactions/DrawTool.js";
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 Map from "@giro3d/giro3d/entities/Map.js";
import Inspector from "@giro3d/giro3d/gui/Inspector.js";
import Coordinates from "@giro3d/giro3d/core/geographic/Coordinates.js";
import Shape from "@giro3d/giro3d/entities/Shape.js";
import WmtsSource from "@giro3d/giro3d/sources/WmtsSource.js";
import BilFormat from "@giro3d/giro3d/formats/BilFormat.js";
import ColorLayer from "@giro3d/giro3d/core/layer/ColorLayer.js";
import ColorMap from "@giro3d/giro3d/core/ColorMap.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 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 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;
}

Instance.registerCRS(
  "EPSG:2154",
  "+proj=lcc +lat_0=46.5 +lon_0=3 +lat_1=49 +lat_2=44 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs",
);
Instance.registerCRS(
  "IGNF:WGS84G",
  'GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]',
);

const extent = Extent.fromCenterAndSize(
  "EPSG:2154",
  { x: 674_675, y: 6_442_569 },
  30_000,
  30_000,
);

const instance = new Instance({
  target: "view",
  crs: extent.crs,
  backgroundColor: null,
});

const map = new Map({
  extent,
  lighting: {
    enabled: true,
    elevationLayersOnly: true,
  },
  side: DoubleSide,
  backgroundColor: "white",
});

instance.add(map);

const noDataValue = -1000;

const capabilitiesUrl =
  "https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetCapabilities";

WmtsSource.fromCapabilities(capabilitiesUrl, {
  layer: "ELEVATION.ELEVATIONGRIDCOVERAGE.HIGHRES",
  format: new BilFormat(),
  noDataValue,
})
  .then((elevationWmts) => {
    map.addLayer(
      new ElevationLayer({
        extent: map.extent,
        preloadImages: true,
        minmax: { min: 0, max: 5000 },
        noDataOptions: {
          replaceNoData: false,
        },
        colorMap: new ColorMap({
          colors: makeColorRamp("bathymetry"),
          min: 500,
          max: 1800,
        }),
        source: elevationWmts,
      }),
    );
  })
  .catch(console.error);

let colorLayer;

WmtsSource.fromCapabilities(capabilitiesUrl, {
  layer: "HR.ORTHOIMAGERY.ORTHOPHOTOS",
})
  .then((orthophotoWmts) => {
    colorLayer = new ColorLayer({
      preloadImages: true,
      extent: map.extent,
      source: orthophotoWmts,
    });

    map.addLayer(colorLayer);
  })
  .catch(console.error);

const center = extent.centerAsVector2();

instance.view.camera.position.set(center.x - 4000, center.y - 4000, 7300);

const controls = new MapControls(instance.view.camera, instance.domElement);
controls.target.set(center.x, center.y, 300);
instance.view.setControls(controls);

// We use the DrawTool to draw the path on the map.
const measureTool = new DrawTool({ instance });

// The 3D line that will follow the elevation profile
const measure = new Shape({
  showVertices: false,
  showLine: true,
  vertexRadius: 3,
});
measure.name = "profile";
measure.renderOrder = 10;

instance.add(measure);

function updateMarkers(points) {
  measure.setPoints(points);
}

let currentChart;

const canvas = document.getElementById("profileChart");
const chartContainer = document.getElementById("chartContainer");

const canvasHeight = canvas.clientHeight;
const canvasWidth = canvas.clientWidth;

function updateProfileChart(points) {
  ChartJS.Chart.register(
    ChartJS.LinearScale,
    ChartJS.LineController,
    ChartJS.PointElement,
    ChartJS.LineElement,
    ChartJS.Title,
    ChartJS.Legend,
    ChartJS.Filler,
  );

  const data = [];
  let distance = 0;

  // Let's process our datapoints.
  // On the X axis, we will have the horizontal distance along the curve.
  // On the Y axis, we will have the elevations.
  for (let i = 0; i < points.length; i++) {
    const pt = points[i];

    if (i > 0) {
      const prev = new Vector2(points[i - 1].x, points[i - 1].y);
      const curr = new Vector2(points[i].x, points[i].y);

      distance += Math.round(curr.distanceTo(prev));
    }

    data.push({ x: distance, y: pt.z });
  }

  const dataset = {
    label: "Profile",
    data,
    fill: true,
    borderWidth: 3,
    pointRadius: 0,
    backgroundColor: "#2978b430",
    borderColor: "#2978b480",
    yAxisID: "y",
  };

  currentChart?.destroy();

  // Let's build our elevation profile chart.
  const chart = new ChartJS.Chart(canvas, {
    type: "line",
    data: {
      datasets: [dataset],
    },
    options: {
      animation: true,
      parsing: false,
      responsive: true,
      maintainAspectRatio: true,
      aspectRatio: canvasWidth / canvasHeight,
      plugins: {
        legend: {
          display: false,
          position: "bottom",
        },
        title: {
          display: true,
          text: "Elevation profile",
        },
      },
      scales: {
        x: {
          display: true,
          bounds: "data",
          type: "linear",
          title: {
            display: true,
            text: "horizontal distance (meters)",
          },
        },
        y: {
          bounds: "ticks",
          type: "linear",
          position: "left",
          title: {
            display: true,
            text: "elevation (meters)",
          },
        },
      },
    },
  });

  currentChart = chart;

  chartContainer.style.display = "block";
}

function computeElevationProfile() {
  // We first start by drawing a LineString on the map.
  return measureTool.createLineString().then((lineString) => {
    if (lineString == null) {
      return;
    }

    const start = performance.now();

    // Then we need to sample this line according to the number of samples
    // selected by the user. We are using a THREE.js CurvePath for that.

    const path = new CurvePath();

    const vertices = lineString.points;

    // For each pair of coordinates, we create a linearly interpolated curve
    for (let i = 0; i < vertices.length - 1; i++) {
      const v0 = vertices[i];
      const v1 = vertices[i + 1];

      const line = new LineCurve(
        new Vector2(v0.x, v0.y),
        new Vector2(v1.x, v1.y),
      );

      path.add(line);
    }

    // And then we sample this curve to have our evenly spaced points

    const sampleCount = document.getElementById("sampleCount").valueAsNumber;
    const points = path.getSpacedPoints(sampleCount - 1);

    const chartPoints = [];

    for (const point of points) {
      const coordinates = new Coordinates(extent.crs, point.x, point.y, 0);

      // Get the elevation for our current coordinate
      const result = map.getElevation({ coordinates });

      // Elevation sampling can return zero or more samples:
      // - Zero sample happens if the coordinate is outside the map's extent
      //   or if no data has been loaded yet.
      // - More than one sample happens because the samples are taken from map tiles, and
      //   they are organized in a hierarchical grid, where parent tiles overlap their children.
      if (result.samples.length > 0) {
        // Let's sort the samples to get the highest resolution sample first
        result.samples.sort((a, b) => a.resolution - b.resolution);

        const elevation = result.samples[0].elevation;

        // Let's populate or list of data points.
        chartPoints.push(new Vector3(point.x, point.y, elevation));
      }
    }

    updateMarkers(chartPoints);
    updateProfileChart(chartPoints);

    // Remove the temporary line
    instance.remove(lineString);

    instance.notifyChange();

    const end = performance.now();
    console.log(`elapsed: ${(end - start).toFixed(1)} ms`);
  });
}

bindButton("start", (button) => {
  button.disabled = true;

  computeElevationProfile().then(() => {
    button.disabled = false;
  });
});
bindButton("closeChart", () => {
  chartContainer.style.display = "none";
});

Inspector.attach("inspector", instance);

const parameters = {
  showLineLabel: false,
};

bindToggle("showLength", (v) => {
  parameters.showLineLabel = v;
  measure.showLineLabel = v;
});
bindToggle("showColorLayer", (v) => {
  colorLayer.visible = v;
  instance.notifyChange(map);
});

const hoveredPoint = new Shape({
  vertexRadius: 6,
  showVertexLabels: true,
  vertexLabelFormatter: ({ position }) => {
    return `${position.z.toFixed(0)}m`;
  },
});
hoveredPoint.name = "hovered-point";
hoveredPoint.points.push(new Vector3());
hoveredPoint.renderOrder = measure.renderOrder + 2;
hoveredPoint.color = measure.color;
hoveredPoint.visible = false;

const markerHtmlElement = document.createElement("div");
markerHtmlElement.style.paddingBottom = "4rem";
const span = document.createElement("span");
span.classList.value = "badge rounded-pill text-bg-primary";
span.innerText = "?";
markerHtmlElement.appendChild(span);

const hoveredLabel = new CSS2DObject(markerHtmlElement);

hoveredPoint.object3d.add(hoveredLabel);

instance.add(hoveredPoint);

function pick(ev) {
  const picked = instance.pickObjectsAt(ev);
  hoveredPoint.visible = false;
  hoveredLabel.visible = false;

  measure.showLineLabel = parameters.showLineLabel;

  if (picked && picked.length > 0) {
    for (const pick of picked) {
      if (pick.entity === measure) {
        measure.showLineLabel = false;

        const { point } = measure.getClosestPointOnLine(pick.point);

        hoveredPoint.updatePoint(0, point);

        hoveredPoint.visible = true;
        hoveredLabel.visible = true;

        break;
      }
    }
  }

  instance.notifyChange();
}

instance.domElement.addEventListener("mousemove", pick);
index.html
<!doctype html>
<html lang="en">
  <head>
    <title>Elevation profile</title>
    <meta charset="UTF-8" />
    <meta name="name" content="map_elevation_profile" />
    <meta
      name="description"
      content="Create an elevation profile using a Map and a path."
    />
    <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 pe-none">
      <div class="card pe-auto">
        <h5 class="card-header">Options</h5>

        <!-- tooltip -->
        <span
          class="badge bg-secondary position-absolute top-0 end-0 m-2"
          data-bs-toggle="popover"
          data-bs-content="pickingHelper"
          >?</span
        >
        <p class="card-text d-none" id="pickingHelper">
          Clik on the <b>Create profile</b> button to draw a path on the map.
          Use right-click to complete the drawing.
        </p>

        <div class="card-body">
          <div class="form-check form-switch mb-2">
            <input
              class="form-check-input"
              type="checkbox"
              checked
              role="switch"
              id="showColorLayer"
              autocomplete="off"
            />
            <label class="form-check-label" for="showColorLayer"
              >Show color layer</label
            >
          </div>

          <div class="form-check form-switch mb-2">
            <input
              class="form-check-input"
              type="checkbox"
              role="switch"
              id="showLength"
              autocomplete="off"
            />
            <label class="form-check-label" for="showLength"
              >Show line length</label
            >
          </div>

          <!-- Sample count -->
          <div class="mb-3">
            <div class="input-group" style="width: 12rem">
              <input
                id="sampleCount"
                type="number"
                min="2"
                max="1000"
                value="200"
                class="form-control"
              />
              <span class="input-group-text">samples</span>
            </div>
          </div>

          <!-- Start elevation profile -->
          <button type="button" class="w-100 btn btn-primary" id="start">
            <i class="bi bi-graph-up"></i>
            Create profile
          </button>
        </div>
      </div>
    </div>

    <div
      class="bg-body"
      id="chartContainer"
      style="
        display: none;
        position: absolute;
        left: 0;
        bottom: 1.3rem;
        width: 100%;
        height: 15rem;
        padding: 1rem;
      "
    >
      <button
        type="button"
        class="btn-close"
        id="closeChart"
        aria-label="Close"
      ></button>

      <!-- The canvas that will host the chart -->
      <canvas id="profileChart" style="width: 100%; height: 15rem"></canvas>
    </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": "map_elevation_profile",
    "dependencies": {
        "colormap": "^2.3.2",
        "chart.js": "^3.9.1",
        "@giro3d/giro3d": "0.42.4"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}