Skip to content
Measure Area and Bearing

Measure Area and Bearing in Maptoolkit Maps JS

Distance is the first measurement people want and rarely the last. Area answers how big a parcel, a lake or a search zone is, and bearing answers which way a leg runs, which matters as soon as anyone has to walk it. Both come from the same click-collected coordinates, and neither needs a geometry library.

const API_KEY = 'YOUR_API_KEY';
    const R = 6371008.8; // IUGG mean Earth radius, metres
    const rad = (d) => d * Math.PI / 180;
    const deg = (r) => r * 180 / Math.PI;

    let points = [];

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

    map.addControl(new maptoolkit.NavigationControl(), 'top-right');

    // Haversine: great-circle distance in metres.
    function distance([lng1, lat1], [lng2, lat2]) {
        const dLat = rad(lat2 - lat1), dLng = rad(lng2 - lng1);
        const a = Math.sin(dLat / 2) ** 2 +
                  Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.sin(dLng / 2) ** 2;
        return 2 * R * Math.asin(Math.sqrt(a));
    }

    // Initial bearing, degrees clockwise from north.
    function bearing([lng1, lat1], [lng2, lat2]) {
        const dLng = rad(lng2 - lng1);
        const y = Math.sin(dLng) * Math.cos(rad(lat2));
        const x = Math.cos(rad(lat1)) * Math.sin(rad(lat2)) -
                  Math.sin(rad(lat1)) * Math.cos(rad(lat2)) * Math.cos(dLng);
        return (deg(Math.atan2(y, x)) + 360) % 360;
    }

    // Spherical excess. A planar shoelace under-reports as the polygon grows.
    function area(ring) {
        if (ring.length < 3) return 0;
        let total = 0;
        for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
            total += rad(ring[i][0] - ring[j][0]) *
                     (2 + Math.sin(rad(ring[j][1])) + Math.sin(rad(ring[i][1])));
        }
        return Math.abs(total * R * R / 2);
    }

    const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
    const compass = (b) => COMPASS[Math.round(b / 45) % 8];

    // Pick the unit from the magnitude. A garden in hectares reads 0.01 ha, a county in
    // metres reads 41000000; both are numbers nobody can hold in their head.
    function formatArea(m2) {
        if (m2 < 10000) return `${Math.round(m2)} m²`;
        if (m2 < 1000000) return `${(m2 / 10000).toFixed(2)} ha`;
        return `${(m2 / 1000000).toFixed(2)} km²`;
    }

    function formatDistance(m) {
        if (m < 1000) return `${Math.round(m)} m`;
        return `${(m / 1000).toFixed(2)} km`;
    }

    function render() {
        const el = document.getElementById('readout');
        if (points.length < 2) {
            el.innerHTML = '<div class="hint">Click to add points. Three or more give an area.</div>';
            return;
        }

        let perimeter = 0;
        for (let i = 1; i < points.length; i++) perimeter += distance(points[i - 1], points[i]);

        const last = bearing(points[points.length - 2], points[points.length - 1]);
        const closed = points.length > 2;
        const m2 = closed ? area(points) : 0;
        const closing = closed ? distance(points[points.length - 1], points[0]) : 0;

        el.innerHTML = `
            <dl>
              ${closed ? `<dt>Area</dt><dd>${formatArea(m2)}</dd>` : ''}
              <dt>${closed ? 'Perimeter' : 'Length'}</dt>
              <dd>${formatDistance(perimeter + closing)}</dd>
              <dt>Last leg</dt><dd>${Math.round(last)}&deg; ${compass(last)}</dd>
              <dt>Points</dt><dd>${points.length}</dd>
            </dl>
            <button id="reset">Reset</button>`;

        document.getElementById('reset').addEventListener('click', () => {
            points = [];
            update();
            render();
        });
    }

    function update() {
        map.getSource('vertices').setData({
            type: 'FeatureCollection',
            features: points.map((p) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: p } }))
        });

        // A Polygon ring has to repeat its first coordinate as its last.
        map.getSource('shape').setData(points.length > 2
            ? { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[...points, points[0]]] } }
            : { type: 'Feature', geometry: { type: 'LineString', coordinates: points } });
    }

    map.on('load', () => {
        map.addSource('shape', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
        map.addSource('vertices', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });

        map.addLayer({
            id: 'fill', type: 'fill', source: 'shape',
            filter: ['==', ['geometry-type'], 'Polygon'],
            paint: { 'fill-color': '#2171b5', 'fill-opacity': 0.2 }
        });
        map.addLayer({
            id: 'outline', type: 'line', source: 'shape',
            paint: { 'line-color': '#2171b5', 'line-width': 2 }
        });
        map.addLayer({
            id: 'vertices', type: 'circle', source: 'vertices',
            paint: { 'circle-radius': 5, 'circle-color': '#fff', 'circle-stroke-color': '#2171b5', 'circle-stroke-width': 2 }
        });

        map.on('click', (e) => {
            points.push([e.lngLat.lng, e.lngLat.lat]);
            update();
            render();
        });

        render();
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Measure Area and Bearing - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Measure polygon area, perimeter and bearing." />
    <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%; }
        #readout {
            position: absolute; top: 10px; left: 10px; z-index: 999;
            background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
            font: 13px/1.6 system-ui, sans-serif; padding: 10px 12px; min-width: 175px;
        }
        #readout dl { display: grid; grid-template-columns: auto auto; gap: 0 16px; margin: 0; }
        #readout dt { color: #666; }
        #readout dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
        #readout button { margin-top: 8px; width: 100%; }
        #readout .hint { color: #777; font-size: 12px; }
    </style>
</head>
<body>
<div id="map"></div>
<div id="readout"></div>
<script>
    const API_KEY = 'YOUR_API_KEY';
    const R = 6371008.8; // IUGG mean Earth radius, metres
    const rad = (d) => d * Math.PI / 180;
    const deg = (r) => r * 180 / Math.PI;

    let points = [];

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

    map.addControl(new maptoolkit.NavigationControl(), 'top-right');

    // Haversine: great-circle distance in metres.
    function distance([lng1, lat1], [lng2, lat2]) {
        const dLat = rad(lat2 - lat1), dLng = rad(lng2 - lng1);
        const a = Math.sin(dLat / 2) ** 2 +
                  Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.sin(dLng / 2) ** 2;
        return 2 * R * Math.asin(Math.sqrt(a));
    }

    // Initial bearing, degrees clockwise from north.
    function bearing([lng1, lat1], [lng2, lat2]) {
        const dLng = rad(lng2 - lng1);
        const y = Math.sin(dLng) * Math.cos(rad(lat2));
        const x = Math.cos(rad(lat1)) * Math.sin(rad(lat2)) -
                  Math.sin(rad(lat1)) * Math.cos(rad(lat2)) * Math.cos(dLng);
        return (deg(Math.atan2(y, x)) + 360) % 360;
    }

    // Spherical excess. A planar shoelace under-reports as the polygon grows.
    function area(ring) {
        if (ring.length < 3) return 0;
        let total = 0;
        for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
            total += rad(ring[i][0] - ring[j][0]) *
                     (2 + Math.sin(rad(ring[j][1])) + Math.sin(rad(ring[i][1])));
        }
        return Math.abs(total * R * R / 2);
    }

    const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
    const compass = (b) => COMPASS[Math.round(b / 45) % 8];

    // Pick the unit from the magnitude. A garden in hectares reads 0.01 ha, a county in
    // metres reads 41000000; both are numbers nobody can hold in their head.
    function formatArea(m2) {
        if (m2 < 10000) return `${Math.round(m2)} m²`;
        if (m2 < 1000000) return `${(m2 / 10000).toFixed(2)} ha`;
        return `${(m2 / 1000000).toFixed(2)} km²`;
    }

    function formatDistance(m) {
        if (m < 1000) return `${Math.round(m)} m`;
        return `${(m / 1000).toFixed(2)} km`;
    }

    function render() {
        const el = document.getElementById('readout');
        if (points.length < 2) {
            el.innerHTML = '<div class="hint">Click to add points. Three or more give an area.</div>';
            return;
        }

        let perimeter = 0;
        for (let i = 1; i < points.length; i++) perimeter += distance(points[i - 1], points[i]);

        const last = bearing(points[points.length - 2], points[points.length - 1]);
        const closed = points.length > 2;
        const m2 = closed ? area(points) : 0;
        const closing = closed ? distance(points[points.length - 1], points[0]) : 0;

        el.innerHTML = `
            <dl>
              ${closed ? `<dt>Area</dt><dd>${formatArea(m2)}</dd>` : ''}
              <dt>${closed ? 'Perimeter' : 'Length'}</dt>
              <dd>${formatDistance(perimeter + closing)}</dd>
              <dt>Last leg</dt><dd>${Math.round(last)}&deg; ${compass(last)}</dd>
              <dt>Points</dt><dd>${points.length}</dd>
            </dl>
            <button id="reset">Reset</button>`;

        document.getElementById('reset').addEventListener('click', () => {
            points = [];
            update();
            render();
        });
    }

    function update() {
        map.getSource('vertices').setData({
            type: 'FeatureCollection',
            features: points.map((p) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: p } }))
        });

        // A Polygon ring has to repeat its first coordinate as its last.
        map.getSource('shape').setData(points.length > 2
            ? { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[...points, points[0]]] } }
            : { type: 'Feature', geometry: { type: 'LineString', coordinates: points } });
    }

    map.on('load', () => {
        map.addSource('shape', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
        map.addSource('vertices', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });

        map.addLayer({
            id: 'fill', type: 'fill', source: 'shape',
            filter: ['==', ['geometry-type'], 'Polygon'],
            paint: { 'fill-color': '#2171b5', 'fill-opacity': 0.2 }
        });
        map.addLayer({
            id: 'outline', type: 'line', source: 'shape',
            paint: { 'line-color': '#2171b5', 'line-width': 2 }
        });
        map.addLayer({
            id: 'vertices', type: 'circle', source: 'vertices',
            paint: { 'circle-radius': 5, 'circle-color': '#fff', 'circle-stroke-color': '#2171b5', 'circle-stroke-width': 2 }
        });

        map.on('click', (e) => {
            points.push([e.lngLat.lng, e.lngLat.lat]);
            update();
            render();
        });

        render();
    });
</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 where clicking adds vertices to a polygon, and show its area in hectares, its perimeter in kilometres and the bearing of the last leg.

How it works

Area uses spherical excess, not the shoelace formula. The planar shoelace treats longitude and latitude as a flat grid, which is fine over a field and wrong over a valley: the error grows with latitude and with size, and it is always an under-report in the northern hemisphere. The spherical version here is a few more characters and correct anywhere. Computing area from screen coordinates instead is worse again, because Web Mercator inflates area towards the poles by a factor of over three in Scandinavia.

Bearing is an initial bearing and it changes along the leg. Following a great circle, the compass heading you set off on is not the one you arrive on, and the two differ by enough to matter over hundreds of kilometres. For a short leg the distinction is invisible; for a long one, say which you mean.

The ring closes explicitly. GeoJSON requires a Polygon ring to repeat its first coordinate as its last, and MapLibre renders an unclosed ring without complaint while area and perimeter come out wrong. Spreading [...points, points[0]] is the whole fix.

Perimeter adds the closing leg once the shape is a polygon but not while it is still a line, because an open measurement should not silently include a segment the user has not drawn.

['==', ['geometry-type'], 'Polygon'] on the fill layer lets one source feed both states. Without it the fill layer tries to render a LineString and the map flickers between shapes as points are added.

The unit has to follow the magnitude. A fixed unit is unreadable at one end of the range or the other: a garden measured in hectares reads 0.01 ha, and a county measured in metres reads 41000000. formatArea switches at the two thresholds people actually use, square metres below a hectare and square kilometres above a hundred, and formatDistance does the same at one kilometre. Hectares are the unit for land in between, which is why they sit in the middle rather than being the only option.

Next steps

Labelling each leg with its own distance and bearing is the next step, and it is a symbol layer with symbol-placement: 'line' over a feature per segment rather than one line.

If the shape is meant to be walked rather than spanned, the Routing API returns the real distance along paths, which is a different and always larger number than the straight line measured here. For “everything within this shape”, testing your own points against it uses the same polygon.