Skip to content

Fix geo fitbounds to choose a compact range across the antimeridian#7837

Open
SharadhNaidu wants to merge 4 commits into
plotly:masterfrom
SharadhNaidu:fix-geo-fitbounds-antimeridian
Open

Fix geo fitbounds to choose a compact range across the antimeridian#7837
SharadhNaidu wants to merge 4 commits into
plotly:masterfrom
SharadhNaidu:fix-geo-fitbounds-antimeridian

Conversation

@SharadhNaidu

@SharadhNaidu SharadhNaidu commented Jun 11, 2026

Copy link
Copy Markdown

fixes #7844.

With fitbounds: "locations" on a geo subplot, points that straddle the antimeridian make the map zoom out far more than it should. The longitude range comes straight from getAutoRange as a plain min/max, so it measures the span the long way around the globe. The issue's example (lon 131.8855 and -179) ends up with a ~311° span, when crossing the antimeridian only needs ~49°.

I added a helper, getFitboundsLonRange, that sorts the longitudes, finds the largest gap between neighbouring points, and if that gap is wider than the one the naive range leaves open across the antimeridian, returns the complementary range instead. The result can exceed 180° (e.g. [131.8855, 181]), but makeRangeBox already handles ranges that cross the antimeridian, so the projection code is untouched.

It runs right after getAutoRange, so it can only shrink the range, never grow it. It deliberately stays out of the way when:

  • a choropleth or location-based scattergeo trace is present (those get their extent from region bounding boxes, not the point longitudes collected here);
  • the data already wraps the whole globe;
  • no interior gap is wider than the antimeridian gap, so ordinary maps render exactly as before.

Tests: unit tests on the helper (straddling, not straddling, whole globe, too few points) plus an integration test that renders both of the issue's cases and checks the fitted range and the projection rotation. The existing fitbounds mocks and the winkel-tripel draw-time test are all location-based or globe-spanning, so none of them shift.

One open question: the range I return is tight, whereas getAutoRange pads a little for marker size, so in the crossing case points can sit right on the edge. Happy to mirror that padding if you'd prefer.

SharadhNaidu added a commit to SharadhNaidu/plotly.js that referenced this pull request Jun 11, 2026
@SharadhNaidu

Copy link
Copy Markdown
Author

can any of the maintainer please review the fix for the issue please ?

@camdecoster

Copy link
Copy Markdown
Contributor

Hello @SharadhNaidu! Thanks for the PR. I'll try to review this, but it might take a bit to get to.

@camdecoster

Copy link
Copy Markdown
Contributor

In the meantime, could you provide some examples that this fixes and some testing steps?

@SharadhNaidu

Copy link
Copy Markdown
Author

Give me some time please , i'll provide you with few examples with before and after , also the testing steps .

@SharadhNaidu

SharadhNaidu commented Jun 15, 2026

Copy link
Copy Markdown
Author

here's the repro from the issue , before/after, and how to test.

example (the case from #7844)

Plotly.newPlot('graph', [{
  type: 'scattergeo', mode: 'markers',
  lat: [43.1155, 32.7157], lon: [131.8855, -179]
}], {
  geo: { fitbounds: 'locations', projection: { type: 'equirectangular' } }
});

the two points sit either side of ±180° before, fitbounds measures the longitude span the long way (~311°) and zooms out to almost the whole world , after it uses the compact crossing range (~49°).

before (v3.6.0) points squashed against opposite edges:

before

after (this PR) compact view across the antimeridian, framed like any other fitbounds map:

after

for reference, the eastern-most = 179 case already worked and is unchanged:

control

what changed:
the range still comes from getAutoRange , but when points straddle ±180 it's swapped for the largest gap complement range (per @rl-utility-man's suggestion) . It only ever shrinks the range , skips choropleth/location traces and whole globe data and I pad the compact range with the same margin getAutoRange uses so markers aren't flush with the frame , for ties (e.g. 91°E and 91°W) it just takes the largest gap deterministically , with no atlantic/pacific preference , happy to document that default.

testing

npm run test-jasmine -- geo
  • Test geo fitbounds longitude range — unit tests on the helper (straddling, non-straddling, whole-globe, too-few-points).
  • Test geo fitbounds with antimeridian-straddling points — renders the -179 case and checks the fitted range + projection rotation, with a 179 control that keeps the naive centring.

Manual: swap -179 for 179 in the snippet — both now give the same compact view.

i'll attach a few more examples and tests .

@SharadhNaidu

SharadhNaidu commented Jun 15, 2026

Copy link
Copy Markdown
Author

a couple more for refrence.

here's a cluster rather than just two points , five markers around the dateline , lon = [170, 175, -178, -175, 179]. Same thing: the naive range comes out [-178, 179] (~357deg, so basically the whole world), and it collapses to [170, 185] (~15deg) with the fix.

Plotly.newPlot('graph', [{
  type: 'scattergeo', mode: 'markers',
  lat: [-18, -16, -14, -20, -22], lon: [170, 175, -178, -175, 179]
}], { geo: { fitbounds: 'locations', projection: { type: 'equirectangular' } } });

before , the cluster ends up split across both edges:

cluster_before

After:

cluster_after

on the tests , in case it saves you a read , the unit block (Test geo fitbounds longitude range) just pins down the helper on its own : it only returns the wrapped range when that's actually tighter , otherwise it hands back null and getAutoRange is left alone (normal data, whole globe data, fewer than two points) . it's really there to show it stays out of the way when it shouldn't trigger the other block (Test geo fitbounds with antimeridian-straddling points) does actual Plotly.newPlot renders and checks the range and rotation come out on the crossing view , plus a non-straddling case that's untouched so ordinary maps don't change .

and since the helper's just a plain module you can sanity-check the logic without a render:

TESTS

node -e "console.log(require('./src/plots/geo/get_fitbounds_lon_range')([131.8855, -179]))"

[ 131.8855, 181 ]

node -e "console.log(require('./src/plots/geo/get_fitbounds_lon_range')([170, 175, -178, -175, 179]))"

[ 170, 185 ]

node -e "console.log(require('./src/plots/geo/get_fitbounds_lon_range')([-10, 0, 20]))"
null

@camdecoster camdecoster self-assigned this Jun 15, 2026
When `fitbounds` point data straddles +/-180 degrees longitude, the naive
[min, max] range from getAutoRange includes the large empty span the long way
round the globe, so the map zooms out far more than necessary
(plotly/plotly.py#5539).

Add getFitboundsLonRange, which finds the widest gap between consecutive
longitudes and returns the complementary, antimeridian-crossing range when it
is more compact. The override is scoped to longitude point data: it is skipped
when a choropleth or location-based scattergeo trace is present (whose region
extents are not captured here) and when the data spans the whole globe.
The antimeridian fix replaced the padded naive range with a tight
[min, max], so markers ended up flush against the frame on straddling
maps while every other fitbounds map leaves a margin. Scale the padding
getAutoRange already applied to the naive range down to the narrower
crossing range and apply it. The padding is symmetric, so the
mid-longitude the projection centers on is unchanged. Loosen the
integration test's range assertion to match.
@SharadhNaidu SharadhNaidu force-pushed the fix-geo-fitbounds-antimeridian branch from 8575782 to 8fd0ab1 Compare June 16, 2026 01:50
@SharadhNaidu

Copy link
Copy Markdown
Author

I dont why the one CI run failed , its an animation/transition test , I dont think i have touch any path/file which required "test/jasmine/tests/transition_test.js" , dont know why it failed but just in case I rebased , to the recent changes .

@camdecoster camdecoster left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great! Thanks for the fix. It works well from my testing. I requested a few changes. Once those are done, this can be merged.

Comment thread src/plots/geo/geo.js

// only visible traces contribute to the autorange above
if(fitTrace.visible !== true) continue;
if(fitTrace.locations) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without checking the array length, an empty array could match here. That seems unlikely, but it could happen.

Suggested change
if(fitTrace.locations) {
if(fitTrace.locations?.length) {

Comment thread src/plots/geo/geo.js
Comment on lines +237 to +241
// For point data straddling the antimeridian (±180°), the naive [min, max]
// longitude range above can include a large empty span; prefer the compact
// crossing range instead. Skipped when a trace contributes region extents
// (choropleth or location-based scattergeo), whose geographic bounds are not
// captured by the point longitudes gathered here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you update this wording?

Suggested change
// For point data straddling the antimeridian (±180°), the naive [min, max]
// longitude range above can include a large empty span; prefer the compact
// crossing range instead. Skipped when a trace contributes region extents
// (choropleth or location-based scattergeo), whose geographic bounds are not
// captured by the point longitudes gathered here.
// For point data straddling the antimeridian (±180°), the naive [min, max]
// longitude range above can include a large empty span; prefer the compact
// crossing range instead. Restricted to fitbounds='locations' with no
// region-bearing traces: choropleth, scattergeo `locations`, and the
// geojson-bbox path used by fitbounds='geojson' + locationmode='geojson-id'
// all carry region extents that per-point lonlat centroids don't capture.

expect(lonRange[1]).toBeGreaterThan(181);
expect(lonRange[1] - lonRange[0]).toBeGreaterThan(49);
expect(lonRange[1] - lonRange[0]).toBeLessThan(70);
expect(geoLayout._subplot.projection.rotate()[0]).toBeCloseTo(-156.4, 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you tighten up this assertion a bit?

Suggested change
expect(geoLayout._subplot.projection.rotate()[0]).toBeCloseTo(-156.4, 0);
expect(geoLayout._subplot.projection.rotate()[0]).toBeCloseTo(-156.44, 1);

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.

[BUG]: fitbounds="locations" thinks the world is flat and goes through longitude zero when dealing with positive and negative longitudes

2 participants