Mapbox GL JS – fitBounds with bearing and/or pitch sets bounds for North aligned, zero pitch map. How to set bounds for rotated map?

I have a JS class to add maps with routes to my page (mapbox-gl-js v3.8.0).

Once the route has loaded, I’m using fitBounds to adjust the map to fill the available space with the route.

This all works fine until I apply a bearing and/or pitch. What seems to happen is that the map is zoomed to fill with the route as if bearing & pitch are both zero, then the bearing & pitch are applied. This has the effect of making the route either too small or too big (depending on orientation etc).

I’ve tried making an array of my points transformed by the bearing and creating a bounds rectangle from that which should in theory work, but it just results in the same end where the size/zoom is optimised for the north aligned view before applying the rotation.

Does anyone have any insight into how I can fill the map container with my rotated route?

The relevant class methods for getting and fitting the route are:

    setView = (bounds, duration = 0) => {
        // bounds should be array of arrays in format [[min_lng, min_lat],[max_lng, max_lat]]
        // duration is animation length in milliseconds
        this.map.fitBounds(bounds, {
            padding: {
                top: this.map_settings.padding.top,
                right: this.map_settings.padding.right,
                bottom: this.map_settings.padding.bottom,
                left: this.map_settings.padding.left,
            },
            pitch: this.map_settings.pitch,
            bearing: this.map_settings.bearing,
            duration: duration
        });
    }

    drawRoute = async () => {
        // build the gps points query string
        const points = this.map_settings.waypoints.map((coord) => [coord.longitude, coord.latitude].join());
        const gps_list = points.join(";");
        const query = await fetch(
            `https://api.mapbox.com/directions/v5/mapbox/${this.map_settings.route_type}/${gps_list}?steps=false&geometries=geojson&access_token=${mapboxgl.accessToken}`,
            { method: "GET" }
        );
        // return if api call not successful
        if (!query.ok) {
            console.warn("Map Block: Error determining route");
            return
        }

        const json = await query.json();
        const data = json.routes[0];
        const route = data.geometry.coordinates;
        const geojson = {
            type: "Feature",
            properties: {},
            geometry: {
                type: "LineString",
                coordinates: route,
            },
        };
        this.map.addLayer({
            id: `route-${this.map_settings.uid}`,
            type: "line",
            source: {
                type: "geojson",
                data: geojson,
            },
            layout: {
                "line-join": "round",
                "line-cap": "round",
            },
            paint: {
                "line-color": "#3887be",
                "line-width": 5,
                "line-opacity": 0.75,
            },
        });

        // set map bounds to fit route
        const bounds = new mapboxgl.LngLatBounds(route[0], route[0]);
        for (const coord of route) {
            bounds.extend(coord);
        }
        this.setView(bounds, 1000);
    }

I’ve tried this from the console with no luck, it was my last iteration of trying to get this to work:

fitRotatedRoute = (routeCoordinates, mapBearing) => {
    // Step 1: Rotate the route coordinates by the negative of the map's bearing
    const radians = (mapBearing * Math.PI) / 180; // Convert map bearing to radians

    // Function to rotate a point by the given angle
    const rotatePoint = ([lng, lat], center, radians) => {
        const dx = lng - center.lng;
        const dy = lat - center.lat;
        return [
            center.lng + dx * Math.cos(radians) - dy * Math.sin(radians),
            center.lat + dx * Math.sin(radians) + dy * Math.cos(radians),
        ];
    };

    // Step 2: Find the centroid of the route (average of coordinates)
    const centroid = routeCoordinates.reduce(
        (acc, [lng, lat]) => ({
            lng: acc.lng + lng / routeCoordinates.length,
            lat: acc.lat + lat / routeCoordinates.length,
        }),
        { lng: 0, lat: 0 }
    );

    // Step 3: Rotate each coordinate by the negative of the map's bearing
    const rotatedPoints = routeCoordinates.map((coord) =>
        rotatePoint(coord, centroid, -radians)
    );

    // Step 4: Calculate the axis-aligned bounding box (AABB) of the rotated coordinates
    const minLng = Math.min(...rotatedPoints.map(([lng]) => lng));
    const maxLng = Math.max(...rotatedPoints.map(([lng]) => lng));
    const minLat = Math.min(...rotatedPoints.map(([_, lat]) => lat));
    const maxLat = Math.max(...rotatedPoints.map(([_, lat]) => lat));

    // Step 5: Fit the bounds on the map using the calculated AABB
    testMap.fitBounds(
        [
            [minLng, minLat], // Southwest corner
            [maxLng, maxLat], // Northeast corner
        ],
        {
            padding: {
                top: mapSettings.padding.top,
                right: mapSettings.padding.right,
                bottom: mapSettings.padding.bottom,
                left: mapSettings.padding.left,
            },
            pitch: mapSettings.pitch,
            bearing: mapBearing, // Apply map bearing (rotation)
            duration: 1000, // Animation duration
        }
    );
}

With pitch and bearing both 0, all works as it should:
pitch and bearing both 0

With bearing -60 & using the class getRoute() method:
pitch zero, bearing -60, using class method

It’s not only not fitted the route, it’s at an even lower zoom level than bearing=0.

With bearing -60 and using the test fitRotatedRoute() function:
pitch zero, bearing -60, using test method

Slightly better zoom level but still a long way off.

If anyone has any insight into how to do this properly, it’d be great to know. MapBox docs only seem to deal with bearing/pitch zero examples.