Skip to content

Commit dbded83

Browse files
authored
Merge pull request #7948 from plotly/cam/7943/handle-polygons-points-antimeridian
fix: Improve handling of auto-fitting with many features and the antimeridian on geo traces
2 parents bc09b2a + 2844e13 commit dbded83

17 files changed

Lines changed: 600 additions & 307 deletions

draftlogs/7948_fix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Fix `geo.fitbounds: 'locations'` when mixing antimeridian-crossing territories with normal ones [[#7948](https://github.com/plotly/plotly.js/pull/7948)]

src/lib/geo_location_utils.js

Lines changed: 46 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -401,16 +401,33 @@ function fetchTraceGeoData(calcData) {
401401
* Returns `null` for input with no extractable coordinates (e.g. `Sphere`,
402402
* empty FeatureCollection).
403403
*/
404-
function computeBbox(d) {
405-
// Extract an array containing all points contained in the GeoJSON object.
406-
// coordAll throws an error on Sphere, malformed inputs, and nullish values.
407-
// Treat any failure as "no bounds" so callers can null-guard uniformly.
408-
let points;
404+
const computeBbox = (d) => boundsOfCoords(coordsOf(d));
405+
406+
/**
407+
* Return every coordinate contained in a GeoJSON object.
408+
*
409+
* @param {object} d - a GeoJSON Feature, Geometry, FeatureCollection, or
410+
* GeometryCollection.
411+
* @return {Array} `[lon, lat]` pairs. Empty for input with nothing extractable:
412+
* coordAll throws on a Sphere, on malformed input and on nullish values, and
413+
* returns nothing for an empty collection.
414+
*/
415+
function coordsOf(d) {
409416
try {
410-
points = coordAll(d);
417+
return coordAll(d);
411418
} catch (_) {
412-
return null;
419+
return [];
413420
}
421+
}
422+
423+
/**
424+
* Bounding box of a list of coordinates, as `computeBbox` describes.
425+
*
426+
* @param {Array} points - `[lon, lat]` pairs
427+
* @return {[number, number, number, number]|null} `[west, south, east, north]`,
428+
* or null when there are no points.
429+
*/
430+
function boundsOfCoords(points) {
414431
if (points.length === 0) return null;
415432
if (points.length === 1) {
416433
const [lon, lat] = points[0];
@@ -428,53 +445,28 @@ function computeBbox(d) {
428445
];
429446
}
430447

448+
const usesFitGeojson = (trace, geoLayout) => geoLayout.fitbounds === 'geojson' && trace.locationmode === 'geojson-id';
449+
431450
/**
432-
* Pick a compact longitude range for `fitbounds`-style auto-framing when the
433-
* data straddles the antimeridian (±180°).
434-
*
435-
* Longitude is cyclic, so the naive [min, max] range used by the autorange
436-
* machinery can include a large empty span when points sit on both sides of
437-
* ±180° (e.g. lon = [131.8855, -179] spans ~311° the long way round, when the
438-
* compact view spans ~49° across the antimeridian). This finds the largest gap
439-
* between consecutive longitudes and, when that gap is wider than the gap across
440-
* the antimeridian, returns the complementary range so the map shows the dense
441-
* cluster of points rather than the empty ocean between them.
442-
*
443-
* The returned upper bound may exceed 180°; downstream `makeRangeBox` (and
444-
* MapLibre's `LngLatBounds`) handle ranges that cross the antimeridian without
445-
* ambiguity.
451+
* Coordinates of a trace's whole geojson, for the `fitbounds: 'geojson'` mode.
446452
*
447-
* @param {Array} lons - longitude values (may contain non-finite entries)
448-
* @return {Array|null} [lonStart, lonEnd] when an antimeridian-crossing range is
449-
* more compact, otherwise null (caller keeps the autorange result).
453+
* @param {object} trace - a `fullData` trace
454+
* @param {object} geoLayout - the subplot's `fullLayout` entry
455+
* @return {Array} `[lon, lat]` pairs. Empty when the trace is in another mode, or
456+
* when the geojson has nothing extractable.
450457
*/
451-
function getFitboundsLonRange(lons) {
452-
const sorted = lons.filter(isFinite).sort((a, b) => a - b);
453-
if (sorted.length < 2) return null;
454-
455-
const n = sorted.length;
456-
const naiveSpan = sorted[n - 1] - sorted[0];
457-
// Data already wraps the whole globe; there is nothing to compact.
458-
if (naiveSpan >= 360) return null;
459-
460-
// Widest gap between consecutive longitudes.
461-
let maxGap = -Infinity;
462-
let gapStart = -1;
463-
for (let i = 0; i < n - 1; i++) {
464-
const gap = sorted[i + 1] - sorted[i];
465-
if (gap > maxGap) {
466-
maxGap = gap;
467-
gapStart = i;
468-
}
469-
}
458+
const fitGeojsonCoords = (trace, geoLayout) =>
459+
usesFitGeojson(trace, geoLayout) ? coordsOf(getTraceGeojson(trace)) : [];
470460

471-
// Only worth wrapping when an interior gap is wider than the gap that the
472-
// naive [min, max] range already leaves open across the antimeridian.
473-
const antimeridianGap = 360 - naiveSpan;
474-
if (maxGap <= antimeridianGap) return null;
475-
476-
return [sorted[gapStart + 1], sorted[gapStart] + ANTIMERIDIAN_LON_SHIFT];
477-
}
461+
/**
462+
* Bounding box of a trace's whole geojson, for the `fitbounds: 'geojson'` mode.
463+
*
464+
* @param {object} trace - a `fullData` trace
465+
* @param {object} geoLayout - the subplot's `fullLayout` entry
466+
* @return {Array|null} `[west, south, east, north]`, or null whenever
467+
* `fitGeojsonCoords` is empty.
468+
*/
469+
const fitGeojsonBbox = (trace, geoLayout) => boundsOfCoords(fitGeojsonCoords(trace, geoLayout));
478470

479471
/**
480472
* Return an unwrapped version of a `[lon0, lon1]` longitude range.
@@ -502,9 +494,12 @@ module.exports = {
502494
getTraceGeojson,
503495
extractTraceFeature,
504496
fetchTraceGeoData,
497+
boundsOfCoords,
505498
computeBbox,
499+
coordsOf,
506500
doesCrossAntiMeridian,
507-
getFitboundsLonRange,
501+
fitGeojsonBbox,
502+
fitGeojsonCoords,
508503
unwrapLonRange,
509504
ANTIMERIDIAN_LON_SHIFT
510505
};

src/plots/geo/constants.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ exports.lataxisSpan = {
142142
};
143143

144144
// Projections whose math doesn't play well with fitbounds
145-
exports.fitboundsIncompatible = new Set(['albers usa', 'craig', 'satellite']);
145+
exports.fitboundsIncompatible = new Set(['albers usa', 'craig', 'peirce quincuncial', 'satellite']);
146146

147147
// defaults for each scope
148148
exports.scopeDefaults = {

src/plots/geo/geo.js

Lines changed: 35 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ var Drawing = require('../../components/drawing');
1616
var Fx = require('../../components/fx');
1717
var Plots = require('../plots');
1818
var Axes = require('../cartesian/axes');
19-
var getAutoRange = require('../cartesian/autorange').getAutoRange;
19+
const { concatExtremes, getAutoRange, makePadFn } = require('../cartesian/autorange');
2020
var dragElement = require('../../components/dragelement');
2121
var prepSelect = require('../../components/selections').prepSelect;
2222
var clearOutline = require('../../components/selections').clearOutline;
@@ -26,7 +26,7 @@ var createGeoZoom = require('./zoom');
2626
var constants = require('./constants');
2727

2828
var geoUtils = require('../../lib/geo_location_utils');
29-
const { getFitboundsLonRange, unwrapLonRange } = geoUtils;
29+
const { unwrapLonRange } = geoUtils;
3030
var topojsonUtils = require('../../lib/topojson_utils');
3131
var topojsonFeature = require('topojson-client').feature;
3232

@@ -237,51 +237,40 @@ proto.updateProjection = function (geoCalcData, fullLayout) {
237237
axLon.range = getAutoRange(gd, axLon);
238238
axLat.range = getAutoRange(gd, axLat);
239239

240-
// For point data straddling the antimeridian (±180°), the naive [min, max]
241-
// longitude range above can include a large empty span; prefer the compact
242-
// crossing range instead. Restricted to fitbounds='locations' with no
243-
// region-bearing traces: choropleth, scattergeo `locations`, and the
244-
// geojson-bbox path used by fitbounds='geojson' + locationmode='geojson-id'
245-
// all carry region extents that per-point lonlat centroids don't capture.
246-
if (!this.hasChoropleth && geoLayout.fitbounds === 'locations') {
247-
var lons = [];
248-
var hasLocationData = false;
249-
250-
for (var i = 0; i < geoCalcData.length; i++) {
251-
var calcTrace = geoCalcData[i];
252-
var fitTrace = calcTrace[0].trace;
253-
254-
// only visible traces contribute to the autorange above
255-
if (fitTrace.visible !== true) continue;
256-
if (fitTrace.locations?.length) {
257-
hasLocationData = true;
258-
break;
259-
}
260-
for (var j = 0; j < calcTrace.length; j++) {
261-
var lonlat = calcTrace[j].lonlat;
262-
if (lonlat) lons.push(lonlat[0]);
263-
}
264-
}
240+
// Min/maxing the per-trace ranges above breaks when data crosses the
241+
// antimeridian, since `computeBbox` unwraps an east edge past 180°. Bounding
242+
// every coordinate at once lets `geoBounds` pick the compact range instead.
243+
const fitCoordParts = [];
265244

266-
if (!hasLocationData) {
267-
var fitLonRange = getFitboundsLonRange(lons);
268-
if (fitLonRange) {
269-
// getFitboundsLonRange returns a tight [min, max]. getAutoRange
270-
// pads the naive range (for marker size and the standard
271-
// margin), so scale that padding to the narrower crossing range
272-
// and apply it, keeping markers off the frame edge as on any
273-
// other fitbounds map. The padding is symmetric, so the
274-
// mid-longitude the projection centers on is unchanged.
275-
var lonDataSpan = Lib.aggNums(Math.max, null, lons) - Lib.aggNums(Math.min, null, lons);
276-
var lonPad =
277-
lonDataSpan > 0
278-
? (((axLon.range[1] - axLon.range[0] - lonDataSpan) / 2) *
279-
(fitLonRange[1] - fitLonRange[0])) /
280-
lonDataSpan
281-
: 0;
282-
axLon.range = [fitLonRange[0] - lonPad, fitLonRange[1] + lonPad];
283-
}
284-
}
245+
for (const calcTrace of geoCalcData) {
246+
const fitTrace = calcTrace[0].trace;
247+
if (fitTrace.visible !== true) continue;
248+
249+
if (fitTrace._module.fitCoords) fitCoordParts.push(fitTrace._module.fitCoords(calcTrace, geoLayout));
250+
}
251+
252+
// Get the extents in the same manner as getAutoRange
253+
const lonExtremes = concatExtremes(gd, axLon);
254+
const lonDataMin = lonExtremes.min.reduce((min, { val }) => Math.min(min, val), Infinity);
255+
const lonDataMax = lonExtremes.max.reduce((max, { val }) => Math.max(max, val), -Infinity);
256+
const lonDataSpan = lonDataMax - lonDataMin;
257+
const fitBbox = geoUtils.boundsOfCoords(fitCoordParts.flat());
258+
const [fitWest, , fitEast] = fitBbox || [];
259+
const fitSpan = fitEast - fitWest;
260+
const useFit = Boolean(fitBbox) && (lonDataSpan > 360 || (lonDataSpan < 360 && fitSpan < lonDataSpan));
261+
262+
// Add padding in the same manner as getAutoRange. Ideally this could use an
263+
// underlying helper function, but that doesn't exist yet so we handle it like this.
264+
if (useFit) {
265+
const getPadMin = makePadFn(fullLayout, axLon, 0);
266+
const getPadMax = makePadFn(fullLayout, axLon, 1);
267+
const padMin = lonExtremes.min.reduce((max, pt) => Math.max(max, getPadMin(pt)), 0);
268+
const padMax = lonExtremes.max.reduce((max, pt) => Math.max(max, getPadMax(pt)), 0);
269+
const usable = axLon._length - padMin - padMax;
270+
const paddedSpan = usable > axLon._length / 10 ? (fitSpan * axLon._length) / usable : fitSpan;
271+
const fitMid = (fitWest + fitEast) / 2;
272+
273+
axLon.range = [fitMid - paddedSpan / 2, fitMid + paddedSpan / 2];
285274
}
286275

287276
var midLon = (axLon.range[0] + axLon.range[1]) / 2;

src/traces/choropleth/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ module.exports = {
66
colorbar: require('../heatmap/colorbar'),
77
calc: require('./calc'),
88
calcGeoJSON: require('./plot').calcGeoJSON,
9+
fitCoords: require('./plot').fitCoords,
910
plot: require('./plot').plot,
1011
style: require('./style').style,
1112
styleOnSelect: require('./style').styleOnSelect,

src/traces/choropleth/plot.js

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,9 @@ function calcGeoJSON(calcTrace, fullLayout) {
3838
? geoUtils.extractTraceFeature(calcTrace)
3939
: getTopojsonFeatures(trace, geo.topojson);
4040

41-
// A falsy result (Sphere feature or malformed/empty geojson) here
42-
// falls back to per-feature bounds — effectively the same as
43-
// fitbounds === 'locations' behavior.
44-
const bboxGeojson =
45-
geoLayout.fitbounds === 'geojson' && locationmode === 'geojson-id'
46-
? geoUtils.computeBbox(geoUtils.getTraceGeojson(trace))
47-
: null;
41+
// A falsy result (another fitbounds mode, or a Sphere/malformed/empty geojson)
42+
// falls back to per-feature bounds, similar to `fitbounds === 'locations'`.
43+
const bboxGeojson = geoUtils.fitGeojsonBbox(trace, geoLayout);
4844

4945
var lonArray = [];
5046
var latArray = [];
@@ -85,7 +81,29 @@ function calcGeoJSON(calcTrace, fullLayout) {
8581
trace._extremes.lat = findExtremes(geoLayout.lataxis._ax, latArray, opts);
8682
}
8783

84+
/**
85+
* Append the coordinates this trace contributes to a subplot-wide `fitbounds`
86+
* bounding box. Keeping it all together allows for proper auto-fitting of
87+
* geometry that crosses the antimeridian.
88+
*
89+
* @param {Array} calcTrace - calcdata for this trace
90+
* @param {object} geoLayout - The subplot's `fullLayout` entry
91+
* @return {Array} `[lon, lat]` pairs
92+
*/
93+
function fitCoords(calcTrace, geoLayout) {
94+
const geojsonCoords = geoUtils.fitGeojsonCoords(calcTrace[0].trace, geoLayout);
95+
if (geojsonCoords.length) return geojsonCoords;
96+
97+
const parts = [];
98+
for (const calcPt of calcTrace) {
99+
if (calcPt.geojson) parts.push(geoUtils.coordsOf(calcPt.geojson));
100+
}
101+
102+
return parts.flat();
103+
}
104+
88105
module.exports = {
89106
calcGeoJSON,
107+
fitCoords,
90108
plot
91109
};

src/traces/scattergeo/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module.exports = {
77
formatLabels: require('./format_labels'),
88
calc: require('./calc'),
99
calcGeoJSON: require('./plot').calcGeoJSON,
10+
fitCoords: require('./plot').fitCoords,
1011
plot: require('./plot').plot,
1112
style: require('./style'),
1213
styleOnSelect: require('../scatter/style').styleOnSelect,

src/traces/scattergeo/plot.js

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,10 @@ function calcGeoJSON(calcTrace, fullLayout) {
100100
var opts = { padded: true };
101101
var lonArray;
102102
var latArray;
103-
104-
const bboxGeojson =
105-
geoLayout.fitbounds === 'geojson' && trace.locationmode === 'geojson-id'
106-
? geoUtils.computeBbox(geoUtils.getTraceGeojson(trace))
107-
: null;
103+
104+
// A falsy result (another fitbounds mode, or a Sphere/malformed/empty geojson)
105+
// falls back to the plotted points, similar to `fitbounds === 'locations'`.
106+
const bboxGeojson = geoUtils.fitGeojsonBbox(trace, geoLayout);
108107

109108
if (bboxGeojson) {
110109
const [west, south, east, north] = bboxGeojson;
@@ -126,7 +125,29 @@ function calcGeoJSON(calcTrace, fullLayout) {
126125
trace._extremes.lat = findExtremes(geoLayout.lataxis._ax, latArray, opts);
127126
}
128127

128+
/**
129+
* Append the coordinates this trace contributes to a subplot-wide `fitbounds`
130+
* bounding box. Keeping it all together allows for proper auto-fitting of
131+
* geometry that crosses the antimeridian.
132+
*
133+
* @param {Array} calcTrace - calcdata for this trace
134+
* @param {object} geoLayout - The subplot's `fullLayout` entry
135+
* @return {Array} `[lon, lat]` pairs
136+
*/
137+
function fitCoords(calcTrace, geoLayout) {
138+
const geojsonCoords = geoUtils.fitGeojsonCoords(calcTrace[0].trace, geoLayout);
139+
if (geojsonCoords.length) return geojsonCoords;
140+
141+
const coords = [];
142+
for (const calcPt of calcTrace) {
143+
if (calcPt.lonlat && isFinite(calcPt.lonlat[0])) coords.push(calcPt.lonlat);
144+
}
145+
146+
return coords;
147+
}
148+
129149
module.exports = {
130-
calcGeoJSON: calcGeoJSON,
131-
plot: plot
150+
calcGeoJSON,
151+
fitCoords,
152+
plot
132153
};
9.84 KB
Loading
6.66 KB
Loading

0 commit comments

Comments
 (0)