Create color maps to interpret elevation, slope and aspect with custom color ramps.

Parameters
100% © Mapbox

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

index.js
import colormap from 'colormap';

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

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

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

import Extent from '@giro3d/giro3d/core/geographic/Extent.js';
import Instance from '@giro3d/giro3d/core/Instance.js';
import ElevationLayer from '@giro3d/giro3d/core/layer/ElevationLayer.js';
import MapboxTerrainFormat from '@giro3d/giro3d/formats/MapboxTerrainFormat.js';
import Map from '@giro3d/giro3d/entities/Map.js';
import Inspector from '@giro3d/giro3d/gui/Inspector.js';
import ColorMap from '@giro3d/giro3d/core/layer/ColorMap.js';
import ColorMapMode from '@giro3d/giro3d/core/layer/ColorMapMode.js';
import TiledImageSource from '@giro3d/giro3d/sources/TiledImageSource.js';
import { ColorLayer } from '@giro3d/giro3d/core/layer/index.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);
    };
}

/**
 * Binds a {@link HTMLInputElement} in slider mode.
 * @param {string} id The id of the <input> element.
 * @param {(v: number) => void} onChange The callback when the slider value changes.
 * @returns {(v: number) => void} The function to update the value from outside.
 */
function bindSlider(id, onChange) {
    /** @type {HTMLInputElement} */
    const slider = document.getElementById(id);
    slider.oninput = function oninput() {
        onChange(slider.valueAsNumber);
    };

    return v => {
        slider.valueAsNumber = v;
        onChange(slider.valueAsNumber);
    };
}

/**
 * Binds a text-value dropdown.
 * @param {string} id The id of the <input> element.
 * @param {(v: string) => void} onChange The callback when the dropdown value changes.
 * @returns {(v: string) => void} The function to update the value from outside.
 */
function bindDropDown(id, onChange) {
    /** @type {HTMLInputElement} */
    const mode = document.getElementById(id);
    mode.onchange = () => {
        onChange(mode.value);
    };

    return v => {
        mode.value = v;
        onChange(mode.value);
    };
}

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

/**
 * 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;
}

// Defines geographic extent: CRS, min/max X, min/max Y
const extent = Extent.fromCenterAndSize('EPSG:3857', { x: 697313, y: 5591324 }, 30000, 30000);

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

// Creates the Giro3D instance
const instance = new Instance(viewerDiv, {
    crs: extent.crs(),
    renderer: {
        // To display the background style
        clearColor: false,
    },
});

// Sets the camera position
const cameraPosition = new Vector3(697119, 5543639, 53043);
instance.camera.camera3D.position.copy(cameraPosition);

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

// Then looks at extent's center
controls.target = extent.centerAsVector3();
controls.saveState();

controls.enableDamping = true;
controls.dampingFactor = 0.2;

instance.useTHREEControls(controls);

const elevationMin = 780;
const elevationMax = 3574;

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

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

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

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

updatePreview(parameters.colors);

// Adds the map that will contain the layers.
const map = new Map('planar', {
    extent,
    backgroundColor: 'cyan',
    doubleSided: true,
    hillshading: {
        enabled: true,
        elevationLayersOnly: true,
    },
});
instance.add(map);

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

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

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

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

map.addLayer(elevationLayer);

let activeLayer = elevationLayer;

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

    updateTransparency();

    updatePreview(parameters.colors);

    instance.notifyChange(map);
}

const setEnableColorMap = bindToggle('enable', v => {
    elevationLayer.visible = true;
    colorLayer.visible = true;
    backgroundLayer.visible = true;

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

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

let suffix = 'm';

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

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

function bindColorMapBounds(callback) {
    /** @type {HTMLInputElement} */
    const lower = document.getElementById('lower');

    /** @type {HTMLInputElement} */
    const upper = document.getElementById('upper');

    callback(lower.valueAsNumber, upper.valueAsNumber);

    function updateLabels() {
        document.getElementById('minLabel').innerText =
            `Lower bound: ${lower.valueAsNumber}${suffix}`;
        document.getElementById('maxLabel').innerText =
            `Upper bound: ${upper.valueAsNumber}${suffix}`;
    }

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

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

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

const canvas = document.getElementById('curve');
const widget = new FunctionCurveEditor.Widget(canvas);

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

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

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

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

setupTransparencyCurve();

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

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

    instance.notifyChange(map);
}

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

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

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

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

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

bindButton('reset', resetToDefaults);

Inspector.attach(document.getElementById('panelDiv'), instance);

// For some reason, not waiting a bit causes the curve editor to be blank on Firefox
setTimeout(resetToDefaults, 100);
index.html
<!doctype html>
<html lang="en">
    <head>
        <title>Color maps</title>
        <meta charset="UTF-8" />
        <meta name="name" content="colormaps" />
        <meta name="description" content="Create color maps to interpret elevation, slope and aspect with custom color ramps." />
        <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/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" style="width: 20rem">
            <!--Parameters -->
            <div class="card">
                <div class="card-header">
                    Parameters
                    <button type="button" id="reset" class="btn btn-sm btn-primary rounded float-end">
                        reset
                    </button>
                </div>

                <div class="card-body" id="top-options">
                    <!-- Preset -->
                    <div class="input-group mb-3">
                        <label class="input-group-text" for="preset">Preset</label>
                        <select class="form-select" id="preset" autocomplete="off">
                            <option value="elevation" selected>
                                Simple elevation layer with color map
                            </option>
                            <option value="elevation+transparency">
                                Elevation layer with color map visible only on the 780-2000m range.
                            </option>
                            <option value="southern-slope">
                                Show the southern slopes of the mountains with a semi-transparent colormap.
                            </option>
                            <option value="flat-terrain">Shows flat areas of the terrain.</option>
                        </select>
                    </div>

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

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

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

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

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

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

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

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

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

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

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

                        <label for="upper" id="maxLabel" class="form-label">Upper bound: 3574m</label>
                        <div class="input-group">
                            <input
                                type="range"
                                min="780"
                                max="3574"
                                value="3574"
                                class="form-range"
                                id="upper"
                                autocomplete="off"
                            />
                        </div>
                    </div>
                </div>
            </div>
        </div>

        <script type="module" src="index.js"></script>
        <script>
            /* activate popovers */
            const popoverTriggerList = [].slice.call(
                document.querySelectorAll('[data-bs-toggle="popover"]'),
            );
            popoverTriggerList.map(
                // bootstrap is used as script in the template, disable warning about undef
                // eslint-disable-next-line no-undef
                popoverTriggerEl =>
                    new bootstrap.Popover(popoverTriggerEl, {
                        trigger: 'hover',
                        placement: 'left',
                        content: document.getElementById(
                            popoverTriggerEl.getAttribute('data-bs-content'),
                        ).innerHTML,
                        html: true,
                    }),
            );
        </script>
    </body>
</html>
package.json
{
    "name": "colormaps",
    "dependencies": {
        "colormap": "^2.3.2",
        "function-curve-editor": "^1.0.16",
        "@giro3d/giro3d": "0.37.2"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}