Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions draftlogs/7891_fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fix GeoJSON bounding-box computation for `choropleth` and `scattergeo` traces whose geometry crosses the antimeridian [[#7891](https://github.com/plotly/plotly.js/pull/7891)]
17 changes: 1 addition & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@
"@plotly/d3-sankey-circular": "0.33.1",
"@plotly/regl": "^2.1.2",
"@turf/area": "^7.1.0",
"@turf/bbox": "^7.1.0",
"@turf/centroid": "^7.1.0",
"@turf/meta": "^7.1.0",
"base64-arraybuffer": "^1.0.2",
"color": "^5.0.0",
"color-normalize": "1.5.0",
Expand Down
50 changes: 44 additions & 6 deletions src/lib/geo_location_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ var d3 = require('@plotly/d3');
const { COUNTRIES, createLookup } = require('country-iso-search');
var { area: turfArea } = require('@turf/area');
var { centroid: turfCentroid } = require('@turf/centroid');
var { bbox: turfBbox } = require('@turf/bbox');
const { coordAll } = require('@turf/meta');
const { geoBounds } = require('d3-geo');

var identity = require('./identity');
var loggers = require('./loggers');
Expand Down Expand Up @@ -385,10 +386,46 @@ function fetchTraceGeoData(calcData) {
return promises;
}

// TODO `turf/bbox` gives wrong result when the input feature/geometry
// crosses the anti-meridian. We should try to implement our own bbox logic.
/**
* Compute a `[west, south, east, north]` bounding box for a GeoJSON object
* (Feature, Geometry, FeatureCollection, or GeometryCollection). This function
* handles geometry that crosses the antimeridian. `north`/`south` will be in the
* range `[-90, 90]`; `west` will typically be in the range `[-180, 180]`; `east`
* will typically be in the range `[-180, 180]`, but when the input crosses the
* antimeridian, it will be shifted by +360° so the range will be `[180, west + 360)`.
*
* @param {object} d - a GeoJSON Feature, Geometry, FeatureCollection, or
* GeometryCollection.
* @return {[number, number, number, number]|null} `[west, south, east, north]`
* in degrees; `east` may exceed 180° when the input crosses ±180°.
* Returns `null` for input with no extractable coordinates (e.g. `Sphere`,
* empty FeatureCollection).
Comment on lines +399 to +402

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.

Can you expand this docstring to clarify the numeric range of each coordinate, and any constraints on the output, and also whether east is guaranteed to exceed 180 if the input crosses the antimeridian?

@camdecoster camdecoster Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll add a bit more info, but some of what you're asking for seems out of scope of a docstring.

*/
function computeBbox(d) {
return turfBbox(d);
// Extract an array containing all points contained in the GeoJSON object.
// coordAll throws an error on Sphere, malformed inputs, and nullish values.
// Treat any failure as "no bounds" so callers can null-guard uniformly.
let points;
try {
points = coordAll(d);
} catch (_) {
return null;
}
if (points.length === 0) return null;
if (points.length === 1) {
const [lon, lat] = points[0];
return [lon, lat, lon, lat];
}
// Pass as MultiPoint (just a bunch of vertices) to avoid
// geobounds treating collection as polygons
const [[west, south], [east, north]] = geoBounds({ type: 'MultiPoint', coordinates: points });

return [
west,
south,
unwrapLonRange([west, east])[1], // Unwrap antimeridian crossing; east may exceed 180°
north
];
}

/**
Expand Down Expand Up @@ -441,21 +478,22 @@ function getFitboundsLonRange(lons) {

/**
* Return an unwrapped version of a `[lon0, lon1]` longitude range.
* When the range crosses the antimeridian (`lon0 > 0`, `lon1 < 0`),
* When the range crosses the antimeridian (`lon0 > lon1`),
* 360 is added to `lon1` to produce a continuous range;
* otherwise the input pair is returned unchanged. Function assumes
* `lon0` is west of `lon1`.
*
* @example
* unwrapLonRange([170, -170]) // → [170, 190] (span = 20°, midpoint = 180°)
* unwrapLonRange([-10, 20]) // → [-10, 20] (no crossing, passthrough)
* unwrapLonRange([-5, -170]) // → [-5, 190] (mixed-sign crossing, e.g. from geoBounds)
*
* @param {[number, number]} lonRange - `[lon0, lon1]`, each in the range [-180, 180]
* @return {[number, number]} The unwrapped range; when the input contract is
* respected, `lon1` falls in the range `[lon0, lon0 + 360)`.
*/
function unwrapLonRange([lon0, lon1]) {
return [lon0, lon0 > 0 && lon1 < 0 ? lon1 + ANTIMERIDIAN_LON_SHIFT : lon1];
return [lon0, lon0 > lon1 ? lon1 + ANTIMERIDIAN_LON_SHIFT : lon1];
}

module.exports = {
Expand Down
59 changes: 36 additions & 23 deletions src/traces/choropleth/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,12 @@ var style = require('./style').style;
function plot(gd, geo, calcData) {
var choroplethLayer = geo.layers.backplot.select('.choroplethlayer');

Lib.makeTraceGroups(choroplethLayer, calcData, 'trace choropleth').each(function(calcTrace) {
Lib.makeTraceGroups(choroplethLayer, calcData, 'trace choropleth').each(function (calcTrace) {
var sel = d3.select(this);

var paths = sel.selectAll('path.choroplethlocation')
.data(Lib.identity);
var paths = sel.selectAll('path.choroplethlocation').data(Lib.identity);

paths.enter().append('path')
.classed('choroplethlocation', true);
paths.enter().append('path').classed('choroplethlocation', true);

paths.exit().remove();

Expand All @@ -35,44 +33,59 @@ function calcGeoJSON(calcTrace, fullLayout) {
var locationmode = trace.locationmode;
var len = trace._length;

var features = locationmode === 'geojson-id' ?
geoUtils.extractTraceFeature(calcTrace) :
getTopojsonFeatures(trace, geo.topojson);
var features =
locationmode === 'geojson-id'
? geoUtils.extractTraceFeature(calcTrace)
: getTopojsonFeatures(trace, geo.topojson);

// A falsy result (Sphere feature or malformed/empty geojson) here
// falls back to per-feature bounds — effectively the same as
// fitbounds === 'locations' behavior.
const bboxGeojson =
geoLayout.fitbounds === 'geojson' && locationmode === 'geojson-id'
? geoUtils.computeBbox(geoUtils.getTraceGeojson(trace))
: null;

var lonArray = [];
var latArray = [];

for(var i = 0; i < len; i++) {
for (var i = 0; i < len; i++) {
var calcPt = calcTrace[i];
var feature = locationmode === 'geojson-id' ?
calcPt.fOut :
geoUtils.locationToFeature(locationmode, calcPt.loc, features);
var feature =
locationmode === 'geojson-id'
? calcPt.fOut
: geoUtils.locationToFeature(locationmode, calcPt.loc, features);

if(feature) {
if (feature) {
calcPt.geojson = feature;
calcPt.ct = feature.properties.ct;
calcPt._polygons = geoUtils.feature2polygons(feature);

var bboxFeature = geoUtils.computeBbox(feature);
lonArray.push(bboxFeature[0], bboxFeature[2]);
latArray.push(bboxFeature[1], bboxFeature[3]);
if (!bboxGeojson) {
const bboxFeature = geoUtils.computeBbox(feature);
if (bboxFeature) {
const [west, south, east, north] = bboxFeature;
lonArray.push(west, east);
latArray.push(south, north);
}
}
} else {
calcPt.geojson = null;
}
}

if(geoLayout.fitbounds === 'geojson' && locationmode === 'geojson-id') {
var bboxGeojson = geoUtils.computeBbox(geoUtils.getTraceGeojson(trace));
lonArray = [bboxGeojson[0], bboxGeojson[2]];
latArray = [bboxGeojson[1], bboxGeojson[3]];
if (bboxGeojson) {
const [west, south, east, north] = bboxGeojson;
lonArray = [west, east];
latArray = [south, north];
}

var opts = {padded: true};
var opts = { padded: true };
trace._extremes.lon = findExtremes(geoLayout.lonaxis._ax, lonArray, opts);
trace._extremes.lat = findExtremes(geoLayout.lataxis._ax, latArray, opts);
}

module.exports = {
calcGeoJSON: calcGeoJSON,
plot: plot
calcGeoJSON,
plot
};
69 changes: 41 additions & 28 deletions src/traces/scattergeo/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,46 +18,52 @@ function plot(gd, geo, calcData) {
var gTraces = Lib.makeTraceGroups(scatterLayer, calcData, 'trace scattergeo');

function removeBADNUM(d, node) {
if(d.lonlat[0] === BADNUM) {
if (d.lonlat[0] === BADNUM) {
d3.select(node).remove();
}
}

// TODO find a way to order the inner nodes on update
gTraces.selectAll('*').remove();

gTraces.each(function(calcTrace) {
gTraces.each(function (calcTrace) {
var s = d3.select(this);
var trace = calcTrace[0].trace;

if(subTypes.hasLines(trace) || trace.fill !== 'none') {
if (subTypes.hasLines(trace) || trace.fill !== 'none') {
var lineCoords = geoJsonUtils.calcTraceToLineCoords(calcTrace);

var lineData = (trace.fill !== 'none') ?
geoJsonUtils.makePolygon(lineCoords) :
geoJsonUtils.makeLine(lineCoords);
var lineData =
trace.fill !== 'none' ? geoJsonUtils.makePolygon(lineCoords) : geoJsonUtils.makeLine(lineCoords);

s.selectAll('path.js-line')
.data([{geojson: lineData, trace: trace}])
.enter().append('path')
.data([{ geojson: lineData, trace: trace }])
.enter()
.append('path')
.classed('js-line', true)
.style('stroke-miterlimit', 2);
}

if(subTypes.hasMarkers(trace)) {
if (subTypes.hasMarkers(trace)) {
s.selectAll('path.point')
.data(Lib.identity)
.enter().append('path')
.enter()
.append('path')
.classed('point', true)
.each(function(calcPt) { removeBADNUM(calcPt, this); });
.each(function (calcPt) {
removeBADNUM(calcPt, this);
});
}

if(subTypes.hasText(trace)) {
if (subTypes.hasText(trace)) {
s.selectAll('g')
.data(Lib.identity)
.enter().append('g')
.enter()
.append('g')
.append('text')
.each(function(calcPt) { removeBADNUM(calcPt, this); });
.each(function (calcPt) {
removeBADNUM(calcPt, this);
});
}

// call style here within topojson request callback
Expand All @@ -72,35 +78,42 @@ function calcGeoJSON(calcTrace, fullLayout) {
var len = trace._length;
var i, calcPt;

if(Lib.isArrayOrTypedArray(trace.locations)) {
if (Lib.isArrayOrTypedArray(trace.locations)) {
var locationmode = trace.locationmode;
var features = locationmode === 'geojson-id' ?
geoUtils.extractTraceFeature(calcTrace) :
getTopojsonFeatures(trace, geo.topojson);
var features =
locationmode === 'geojson-id'
? geoUtils.extractTraceFeature(calcTrace)
: getTopojsonFeatures(trace, geo.topojson);

for(i = 0; i < len; i++) {
for (i = 0; i < len; i++) {
calcPt = calcTrace[i];

var feature = locationmode === 'geojson-id' ?
calcPt.fOut :
geoUtils.locationToFeature(locationmode, calcPt.loc, features);
var feature =
locationmode === 'geojson-id'
? calcPt.fOut
: geoUtils.locationToFeature(locationmode, calcPt.loc, features);

calcPt.lonlat = feature ? feature.properties.ct : [BADNUM, BADNUM];
}
}

var opts = {padded: true};
var opts = { padded: true };
var lonArray;
var latArray;

if(geoLayout.fitbounds === 'geojson' && trace.locationmode === 'geojson-id') {
var bboxGeojson = geoUtils.computeBbox(geoUtils.getTraceGeojson(trace));
lonArray = [bboxGeojson[0], bboxGeojson[2]];
latArray = [bboxGeojson[1], bboxGeojson[3]];
const bboxGeojson =
geoLayout.fitbounds === 'geojson' && trace.locationmode === 'geojson-id'
? geoUtils.computeBbox(geoUtils.getTraceGeojson(trace))
: null;

if (bboxGeojson) {
const [west, south, east, north] = bboxGeojson;
lonArray = [west, east];
latArray = [south, north];
} else {
lonArray = new Array(len);
latArray = new Array(len);
for(i = 0; i < len; i++) {
for (i = 0; i < len; i++) {
calcPt = calcTrace[i];
lonArray[i] = calcPt.lonlat[0];
latArray[i] = calcPt.lonlat[1];
Expand Down
Loading
Loading