Create an elevation profile using a Map and a path.

Options
?

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

samples
100% © U.S. Geological Survey

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, 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/layer/ColorMap.js';

/**
 * Binds a toggle switch or checkbox.
 * @param {string} id The id of the <input> element.
 * @param {(v: boolean) => void} onChange The callback when the dropdown value changes.
 * @returns {(v: boolean) => void} The function to update the value from outside.
 */
function bindToggle(id, onChange) {
    /** @type {HTMLInputElement} */
    const toggle = document.getElementById(id);

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

    return v => {
        toggle.checked = v;
        onChange(toggle.checked);
    };
}

/**
 * Create an array of {@link Color}s from the specified colormap preset.
 * @param {string} preset The name of the colormap preset.
 * @param {boolean} [discrete=false] If `true`, the color array will have 10 steps, otherwise 256.
 * @param {boolean} [invert=false] If `true`, the color array will be reversed.
 * @returns {Color[]} The color array.
 */
function makeColorRamp(preset, discrete = false, invert = false, mirror = false) {
    let nshades = discrete ? 10 : 256;

    // eslint-disable-next-line no-undef
    const values = colormap({ colormap: preset, nshades });
    // eslint-disable-next-line no-undef
    const colors = values.map(v => new Color(v));

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

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

    return colors;
}

/**
 * Binds a button.
 * @param {string} id The id of the <button> element.
 * @param {(button: HTMLButtonElement) => void} onClick The click handler.
 * @returns {HTMLButtonElement} The button element.
 */
function bindButton(id, onClick) {
    const element = document.getElementById(id);
    element.onclick = () => {
        onClick(element);
    };

    return element;
}

// Defines projection that we will use (taken from https://epsg.io/2154, Proj4js section)
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]]',
);

// Defines geographic extent: CRS, min/max X, min/max Y
const extent = Extent.fromCenterAndSize('EPSG:2154', { x: 674_675, y: 6_442_569 }, 30_000, 30_000);

// `viewerDiv` will contain Giro3D' rendering area (the canvas element)
const viewerDiv = document.getElementById('viewerDiv');

// Creates a Giro3D instance
const instance = new Instance(viewerDiv, {
    crs: extent.crs(),
    renderer: {
        clearColor: false,
    },
});

// Creates a map that will contain the layer
const map = new Map('planar', {
    extent,
    hillshading: {
        enabled: true,
        elevationLayersOnly: true,
    },
    doubleSided: true,
    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(makeColorRamp('bathymetry'), 500, 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.camera.camera3D.position.set(center.x - 4000, center.y - 4000, 7300);

// Instanciates controls
const controls = new MapControls(instance.camera.camera3D, instance.domElement);

controls.target.set(center.x, center.y, 300);

instance.useTHREEControls(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('profile', {
    showVertices: false,
    showLine: true,
    vertexRadius: 3,
});
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(v0, v1);

            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(document.getElementById('panelDiv'), 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('hovered-point', {
    vertexRadius: 6,
    showVertexLabels: true,
    vertexLabelFormatter: ({ position }) => {
        return `${position.z.toFixed(0)}m`;
    },
});
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 = '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/next/css/example.css"
        />

        <style>
        #viewerDiv 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="viewerDiv" class="m-0 p-0 w-100 h-100"></div>
        <div id="panelDiv" 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": "git+https://gitlab.com/giro3d/giro3d.git"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}