Skip to content
Add a Simple Custom Layer on a Globe

Add a Simple Custom Layer on a Globe in Maptoolkit Maps JS

A custom layer gives you direct access to the map’s WebGL context so you can render any geometry that stays synchronized with the camera. This example draws a simple shape on a globe projection and includes a button to toggle between globe and Mercator views. Use custom layers when you need rendering capabilities beyond what standard layer types offer, such as custom shaders or GPU-accelerated visual effects.

const API_KEY = 'YOUR_API_KEY';

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        zoom: 3,
        center: [10.0, 52.0],
        attributionControl: { compact: false },
        canvasContextAttributes: { antialias: true }
    });

    // terrainControl:false matters here. Left on, the control switches 3D terrain on the
    // first time the map is tilted, and this custom layer draws at a fixed altitude, so the
    // ground would rise through the scene.
    map.addControl(new maptoolkit.NavigationControl({ terrainControl: false }), 'top-right');

    map.on('style.load', () => {
        map.setProjection({ type: 'globe' });
    });

    document.getElementById('project').addEventListener('click', () => {
        const currentProjection = map.getProjection();
        map.setProjection({
            type: currentProjection.type === 'globe' ? 'mercator' : 'globe',
        });
    });

    const highlightLayer = {
        id: 'highlight',
        type: 'custom',
        shaderMap: new Map(),

        getShader(gl, shaderDescription) {
            if (this.shaderMap.has(shaderDescription.variantName)) {
                return this.shaderMap.get(shaderDescription.variantName);
            }

            const vertexSource = `#version 300 es
            ${shaderDescription.vertexShaderPrelude}
            ${shaderDescription.define}
            in vec2 a_pos;
            void main() {
                gl_Position = projectTile(a_pos);
            }`;

            const fragmentSource = `#version 300 es
            out highp vec4 fragColor;
            void main() {
                fragColor = vec4(1.0, 0.0, 1.0, 0.75);
            }`;

            const vertexShader = gl.createShader(gl.VERTEX_SHADER);
            gl.shaderSource(vertexShader, vertexSource);
            gl.compileShader(vertexShader);

            const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
            gl.shaderSource(fragmentShader, fragmentSource);
            gl.compileShader(fragmentShader);

            const program = gl.createProgram();
            gl.attachShader(program, vertexShader);
            gl.attachShader(program, fragmentShader);
            gl.linkProgram(program);

            this.aPos = gl.getAttribLocation(program, 'a_pos');
            this.shaderMap.set(shaderDescription.variantName, program);
            return program;
        },

        onAdd(map, gl) {
            const helsinki = maptoolkit.MercatorCoordinate.fromLngLat({ lng: 25.004, lat: 60.239 });
            const berlin = maptoolkit.MercatorCoordinate.fromLngLat({ lng: 13.403, lat: 52.562 });
            const kyiv = maptoolkit.MercatorCoordinate.fromLngLat({ lng: 30.498, lat: 50.541 });

            this.buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
            gl.bufferData(
                gl.ARRAY_BUFFER,
                new Float32Array([helsinki.x, helsinki.y, kyiv.x, kyiv.y, berlin.x, berlin.y]),
                gl.STATIC_DRAW
            );
        },

        render(gl, args) {
            const program = this.getShader(gl, args.shaderData);
            gl.useProgram(program);
            gl.uniformMatrix4fv(gl.getUniformLocation(program, 'u_projection_fallback_matrix'), false, args.defaultProjectionData.fallbackMatrix);
            gl.uniformMatrix4fv(gl.getUniformLocation(program, 'u_projection_matrix'), false, args.defaultProjectionData.mainMatrix);
            gl.uniform4f(gl.getUniformLocation(program, 'u_projection_tile_mercator_coords'), ...args.defaultProjectionData.tileMercatorCoords);
            gl.uniform4f(gl.getUniformLocation(program, 'u_projection_clipping_plane'), ...args.defaultProjectionData.clippingPlane);
            gl.uniform1f(gl.getUniformLocation(program, 'u_projection_transition'), args.defaultProjectionData.projectionTransition);

            gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
            gl.enableVertexAttribArray(this.aPos);
            gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, 0, 0);
            gl.enable(gl.BLEND);
            gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 3);
        }
    };

    map.on('load', () => {
        map.addLayer(highlightLayer);
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Add a Simple Custom Layer on a Globe - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Use a custom layer to draw simple WebGL content on a globe." />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
    <style>
        html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
        #map { width: 100%; height: 100%; }
        #project {
            display: block;
            position: absolute;
            top: 20px;
            left: 50%;
            transform: translate(-50%);
            padding: 10px 20px;
            border: none;
            border-radius: 3px;
            font-size: 12px;
            color: #fff;
            background: #ee8a65;
            cursor: pointer;
        }
    </style>
</head>
<body>
<div id="map"></div>
<button id="project">Toggle projection</button>
<script type="module">
    const API_KEY = 'YOUR_API_KEY';

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        zoom: 3,
        center: [10.0, 52.0],
        attributionControl: { compact: false },
        canvasContextAttributes: { antialias: true }
    });

    // terrainControl:false matters here. Left on, the control switches 3D terrain on the
    // first time the map is tilted, and this custom layer draws at a fixed altitude, so the
    // ground would rise through the scene.
    map.addControl(new maptoolkit.NavigationControl({ terrainControl: false }), 'top-right');

    map.on('style.load', () => {
        map.setProjection({ type: 'globe' });
    });

    document.getElementById('project').addEventListener('click', () => {
        const currentProjection = map.getProjection();
        map.setProjection({
            type: currentProjection.type === 'globe' ? 'mercator' : 'globe',
        });
    });

    const highlightLayer = {
        id: 'highlight',
        type: 'custom',
        shaderMap: new Map(),

        getShader(gl, shaderDescription) {
            if (this.shaderMap.has(shaderDescription.variantName)) {
                return this.shaderMap.get(shaderDescription.variantName);
            }

            const vertexSource = `#version 300 es
            ${shaderDescription.vertexShaderPrelude}
            ${shaderDescription.define}
            in vec2 a_pos;
            void main() {
                gl_Position = projectTile(a_pos);
            }`;

            const fragmentSource = `#version 300 es
            out highp vec4 fragColor;
            void main() {
                fragColor = vec4(1.0, 0.0, 1.0, 0.75);
            }`;

            const vertexShader = gl.createShader(gl.VERTEX_SHADER);
            gl.shaderSource(vertexShader, vertexSource);
            gl.compileShader(vertexShader);

            const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
            gl.shaderSource(fragmentShader, fragmentSource);
            gl.compileShader(fragmentShader);

            const program = gl.createProgram();
            gl.attachShader(program, vertexShader);
            gl.attachShader(program, fragmentShader);
            gl.linkProgram(program);

            this.aPos = gl.getAttribLocation(program, 'a_pos');
            this.shaderMap.set(shaderDescription.variantName, program);
            return program;
        },

        onAdd(map, gl) {
            const helsinki = maptoolkit.MercatorCoordinate.fromLngLat({ lng: 25.004, lat: 60.239 });
            const berlin = maptoolkit.MercatorCoordinate.fromLngLat({ lng: 13.403, lat: 52.562 });
            const kyiv = maptoolkit.MercatorCoordinate.fromLngLat({ lng: 30.498, lat: 50.541 });

            this.buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
            gl.bufferData(
                gl.ARRAY_BUFFER,
                new Float32Array([helsinki.x, helsinki.y, kyiv.x, kyiv.y, berlin.x, berlin.y]),
                gl.STATIC_DRAW
            );
        },

        render(gl, args) {
            const program = this.getShader(gl, args.shaderData);
            gl.useProgram(program);
            gl.uniformMatrix4fv(gl.getUniformLocation(program, 'u_projection_fallback_matrix'), false, args.defaultProjectionData.fallbackMatrix);
            gl.uniformMatrix4fv(gl.getUniformLocation(program, 'u_projection_matrix'), false, args.defaultProjectionData.mainMatrix);
            gl.uniform4f(gl.getUniformLocation(program, 'u_projection_tile_mercator_coords'), ...args.defaultProjectionData.tileMercatorCoords);
            gl.uniform4f(gl.getUniformLocation(program, 'u_projection_clipping_plane'), ...args.defaultProjectionData.clippingPlane);
            gl.uniform1f(gl.getUniformLocation(program, 'u_projection_transition'), args.defaultProjectionData.projectionTransition);

            gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
            gl.enableVertexAttribArray(this.aPos);
            gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, 0, 0);
            gl.enable(gl.BLEND);
            gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 3);
        }
    };

    map.on('load', () => {
        map.addLayer(highlightLayer);
    });
</script>
</body>
</html>

Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.

Use the Maptoolkit Connector. Create an interactive map with zoom level 3, centered around [10.0, 52.0], using globe projection. Draw a custom WebGL triangle between Helsinki, Berlin, and Kyiv, with a button to toggle between globe and Mercator projection.

How it works

A custom layer is the escape hatch: type: 'custom' with renderingMode: '3d' hands you the map’s own WebGL context in onAdd(map, gl), and the map calls your render(gl, matrix) on every frame.

Two details make it work rather than fight the map. The three.js renderer is constructed with canvas: map.getCanvas() and context: gl, so it draws into the map’s canvas instead of its own. And renderer.autoClear = false, so three.js does not wipe the map out from under your scene on each frame.

The matrix passed to render is the map’s current view-projection matrix. Assigning it to the camera is what keeps the model locked to its location while you pan, zoom and tilt, rather than floating over the screen.

On a globe the matrix also carries the projection, so geometry you draw follows the sphere’s curvature without extra work on your side. That is the reason to draw through a custom layer rather than overlaying a separate canvas: an overlay stays flat and slides out of place as soon as the map moves.

Next steps

A custom layer is worth reaching for when the style spec cannot express what you need, so the next step is deciding whether yours can: arcs between cities, a particle field, a shader effect over the whole map. Anything that is points, lines or polygons with data-driven styling is cheaper and more maintainable as an ordinary layer.

If you do go further, three.js gives you a scene graph, model loading and lighting on top of the same bridge, which is a large step up from raw WebGL for anything with geometry in it.