Skip to content

fix(android): fit camera to zero-height bounds - #4352

Closed
RobJacobson wants to merge 1 commit into
rnmapbox:mainfrom
RobJacobson:codex/camera-zero-height-bounds
Closed

RobJacobson wants to merge 1 commit into
rnmapbox:mainfrom
RobJacobson:codex/camera-zero-height-bounds

Conversation

@RobJacobson

Copy link
Copy Markdown
Contributor

Description

Addresses the Android zero-height bounds case in #4050.

When both bounds corners share a latitude, Mapbox's camera-fitting method returns zoom 0 even when the longitude span is nonzero. CameraStop now expands fitting bounds by 0.000001 degrees north and south before calling the SDK. This preserves the bounds' midpoint and longitudes while giving the fitter a nonzero latitude span.

The expansion applies only to camera fitting. toBounds() continues to return exact bounds for camera constraints, and nonzero-height fitting bounds remain unchanged. This PR adds three regression tests covering the midpoint, exact constraints, vertical/thin bounds, and valid latitude limits.

Checklist

  • I've read CONTRIBUTING.md and .github/REPRODUCING.md
  • I updated the doc/other generated code by running yarn generate in the root folder (no generated differences)
  • I have tested the change on the /example app.
    • In V11 mode/ios
    • In New Architecture mode/ios
    • In V11 mode/android
    • In New Architecture mode/android
  • I added/updated a sample if a new feature was implemented (/example) — not applicable; this is a bug fix. The standalone reproducer is included below and is not committed.

Before/after verification

The same standalone component was run in rebuilt unfixed and fixed Android example apps with Mapbox 11.23.1, React Native 0.84.1, and New Architecture enabled, on an Android 17 arm64 emulator with 16 KB pages. The map measured 411.4 × 811.4 React Native screen points.

Build Applied zoom West point x East point x Horizontal span Result
Unfixed 0.664 204.486 206.514 2.028 FAIL
Fixed 8.327 0.000 411.021 411.021 PASS

The check requires both original coordinates to lie within the viewport and their horizontal span to be within 3 points of the map width. This rejects the world-scale view even though both points technically remain on screen. The native fitter returns zoom 0 before the workaround; the SDK constrains the applied camera to 0.664 in this example.

Earlier native-only verification also compared the actual bounds helper against unmodified bounds on Mapbox 11.15.3 and 11.23.1. Both versions returned 0 before expansion and 8.326850767922837 after expansion for the original horizontal coordinates. Eleven native scenarios passed per version, including southern/equatorial/northern latitudes, single-point and tiny bounds, vertical/diagonal bounds, bearing 45 degrees, and pitch 30 degrees. Nonzero-height cases produced identical camera options before and after the helper.

Additional checks:

  • yarn test: lint passed; 25 suites and 164 JavaScript unit tests passed.
  • Android LatLngBoundsTest: 3 tests passed; the changed Android library and example app compiled successfully.
  • yarn generate: completed without generated changes.
  • git diff --check: passed.
Captured example-app results
Before:
[issue4050] FAIL: zoom 0.664, points [[204.48624999008402,315.6655860078399],[206.51375000991612,315.6655860078399]], map 411.4x811.4

After:
[issue4050] PASS: zoom 8.327, points [[0,405.5],[411.02125919905166,405.5]], map 411.4x811.4

Component to reproduce the issue you're fixing

Replace example/src/examples/BugReportExample.js with the component below and open the Bug Report Template example. It uses a local blank style and a dummy access token, with no external data or additional libraries. It automatically fits once the style loads; the button repeats the same fit. A failing build displays FAIL, and the fixed build displays PASS.

BugReportExample.js
import React, { useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
import Mapbox, { Camera, MapView, MarkerView } from "@rnmapbox/maps";

Mapbox.setAccessToken("pk.investigation");

const west = [-122.1, 37];
const east = [-121.2, 37];
const blankStyle = JSON.stringify({ version: 8, sources: {}, layers: [] });

export default function BugReportExample() {
  const map = useRef(null);
  const camera = useRef(null);
  const size = useRef({ width: 0, height: 0 });
  const pending = useRef(false);
  const started = useRef(false);
  const [result, setResult] = useState(
    "Tap Fit horizontal bounds after the map loads",
  );
  const fit = () => {
    pending.current = true;
    setResult("Fitting...");
    camera.current.setCamera({
      bounds: { ne: east, sw: west },
      animationDuration: 300,
    });
  };
  const verify = async () => {
    if (!pending.current) return;
    pending.current = false;
    const zoom = await map.current.getZoom();
    const points = await Promise.all(
      [west, east].map((point) => map.current.getPointInView(point)),
    );
    const { width, height } = size.current;
    const span = Math.abs(points[1][0] - points[0][0]);
    const pass =
      span >= width - 3 &&
      points.every(
        ([x, y]) => x >= -2 && x <= width + 2 && y >= -2 && y <= height + 2,
      );
    const text = `${pass ? "PASS" : "FAIL"}: zoom ${zoom.toFixed(3)}, points ${JSON.stringify(points)}, map ${width.toFixed(1)}x${height.toFixed(1)}`;
    console.log("[issue4050]", text);
    setResult(text);
  };
  return (
    <View style={{ flex: 1 }}>
      <MapView
        ref={map}
        style={{ flex: 1 }}
        styleJSON={blankStyle}
        onLayout={({ nativeEvent: { layout } }) => {
          size.current = layout;
        }}
        onMapIdle={verify}
        onDidFinishLoadingStyle={() => {
          if (!started.current) {
            started.current = true;
            setTimeout(fit, 800);
          }
        }}
      >
        <Camera
          ref={camera}
          defaultSettings={{ centerCoordinate: west, zoomLevel: 14 }}
        />
        <MarkerView coordinate={west} allowOverlap>
          <View style={{ width: 20, height: 20, backgroundColor: "red" }} />
        </MarkerView>
        <MarkerView coordinate={east} allowOverlap>
          <View style={{ width: 20, height: 20, backgroundColor: "orange" }} />
        </MarkerView>
      </MapView>
      <Pressable style={{ padding: 16 }} onPress={fit}>
        <Text>Fit horizontal bounds</Text>
      </Pressable>
      <Text style={{ padding: 16 }}>{result}</Text>
    </View>
  );
}

- Expand zero-height camera-fitting bounds symmetrically by one millionth of a degree.
- Preserve exact camera constraints and nonzero-height bounds.
- Add regression coverage for the midpoint, unchanged bounds, and latitude limits.
@RobJacobson
RobJacobson marked this pull request as ready for review October 13, 2026 03:17
@RobJacobson

Copy link
Copy Markdown
Contributor Author

Superseded by #4351, which now contains the Android degenerate-bounds fix and regression coverage together with the #4217 padding fix. I’m consolidating the issue work in that PR so #4050 will close when the combined change merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant