diff --git a/draftlogs/7948_fix.md b/draftlogs/7948_fix.md new file mode 100644 index 00000000000..c32fb180f2b --- /dev/null +++ b/draftlogs/7948_fix.md @@ -0,0 +1 @@ +- Fix `geo.fitbounds: 'locations'` when mixing antimeridian-crossing territories with normal ones [[#7948](https://github.com/plotly/plotly.js/pull/7948)] diff --git a/src/lib/geo_location_utils.js b/src/lib/geo_location_utils.js index a3f8a60c634..8076a2059cc 100644 --- a/src/lib/geo_location_utils.js +++ b/src/lib/geo_location_utils.js @@ -401,16 +401,33 @@ function fetchTraceGeoData(calcData) { * Returns `null` for input with no extractable coordinates (e.g. `Sphere`, * empty FeatureCollection). */ -function computeBbox(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; +const computeBbox = (d) => boundsOfCoords(coordsOf(d)); + +/** + * Return every coordinate contained in a GeoJSON object. + * + * @param {object} d - a GeoJSON Feature, Geometry, FeatureCollection, or + * GeometryCollection. + * @return {Array} `[lon, lat]` pairs. Empty for input with nothing extractable: + * coordAll throws on a Sphere, on malformed input and on nullish values, and + * returns nothing for an empty collection. + */ +function coordsOf(d) { try { - points = coordAll(d); + return coordAll(d); } catch (_) { - return null; + return []; } +} + +/** + * Bounding box of a list of coordinates, as `computeBbox` describes. + * + * @param {Array} points - `[lon, lat]` pairs + * @return {[number, number, number, number]|null} `[west, south, east, north]`, + * or null when there are no points. + */ +function boundsOfCoords(points) { if (points.length === 0) return null; if (points.length === 1) { const [lon, lat] = points[0]; @@ -428,53 +445,28 @@ function computeBbox(d) { ]; } +const usesFitGeojson = (trace, geoLayout) => geoLayout.fitbounds === 'geojson' && trace.locationmode === 'geojson-id'; + /** - * Pick a compact longitude range for `fitbounds`-style auto-framing when the - * data straddles the antimeridian (±180°). - * - * Longitude is cyclic, so the naive [min, max] range used by the autorange - * machinery can include a large empty span when points sit on both sides of - * ±180° (e.g. lon = [131.8855, -179] spans ~311° the long way round, when the - * compact view spans ~49° across the antimeridian). This finds the largest gap - * between consecutive longitudes and, when that gap is wider than the gap across - * the antimeridian, returns the complementary range so the map shows the dense - * cluster of points rather than the empty ocean between them. - * - * The returned upper bound may exceed 180°; downstream `makeRangeBox` (and - * MapLibre's `LngLatBounds`) handle ranges that cross the antimeridian without - * ambiguity. + * Coordinates of a trace's whole geojson, for the `fitbounds: 'geojson'` mode. * - * @param {Array} lons - longitude values (may contain non-finite entries) - * @return {Array|null} [lonStart, lonEnd] when an antimeridian-crossing range is - * more compact, otherwise null (caller keeps the autorange result). + * @param {object} trace - a `fullData` trace + * @param {object} geoLayout - the subplot's `fullLayout` entry + * @return {Array} `[lon, lat]` pairs. Empty when the trace is in another mode, or + * when the geojson has nothing extractable. */ -function getFitboundsLonRange(lons) { - const sorted = lons.filter(isFinite).sort((a, b) => a - b); - if (sorted.length < 2) return null; - - const n = sorted.length; - const naiveSpan = sorted[n - 1] - sorted[0]; - // Data already wraps the whole globe; there is nothing to compact. - if (naiveSpan >= 360) return null; - - // Widest gap between consecutive longitudes. - let maxGap = -Infinity; - let gapStart = -1; - for (let i = 0; i < n - 1; i++) { - const gap = sorted[i + 1] - sorted[i]; - if (gap > maxGap) { - maxGap = gap; - gapStart = i; - } - } +const fitGeojsonCoords = (trace, geoLayout) => + usesFitGeojson(trace, geoLayout) ? coordsOf(getTraceGeojson(trace)) : []; - // Only worth wrapping when an interior gap is wider than the gap that the - // naive [min, max] range already leaves open across the antimeridian. - const antimeridianGap = 360 - naiveSpan; - if (maxGap <= antimeridianGap) return null; - - return [sorted[gapStart + 1], sorted[gapStart] + ANTIMERIDIAN_LON_SHIFT]; -} +/** + * Bounding box of a trace's whole geojson, for the `fitbounds: 'geojson'` mode. + * + * @param {object} trace - a `fullData` trace + * @param {object} geoLayout - the subplot's `fullLayout` entry + * @return {Array|null} `[west, south, east, north]`, or null whenever + * `fitGeojsonCoords` is empty. + */ +const fitGeojsonBbox = (trace, geoLayout) => boundsOfCoords(fitGeojsonCoords(trace, geoLayout)); /** * Return an unwrapped version of a `[lon0, lon1]` longitude range. @@ -502,9 +494,12 @@ module.exports = { getTraceGeojson, extractTraceFeature, fetchTraceGeoData, + boundsOfCoords, computeBbox, + coordsOf, doesCrossAntiMeridian, - getFitboundsLonRange, + fitGeojsonBbox, + fitGeojsonCoords, unwrapLonRange, ANTIMERIDIAN_LON_SHIFT }; diff --git a/src/plots/geo/constants.js b/src/plots/geo/constants.js index a80f9baacbe..6c263146c16 100644 --- a/src/plots/geo/constants.js +++ b/src/plots/geo/constants.js @@ -142,7 +142,7 @@ exports.lataxisSpan = { }; // Projections whose math doesn't play well with fitbounds -exports.fitboundsIncompatible = new Set(['albers usa', 'craig', 'satellite']); +exports.fitboundsIncompatible = new Set(['albers usa', 'craig', 'peirce quincuncial', 'satellite']); // defaults for each scope exports.scopeDefaults = { diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index cba421464af..96457409753 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -16,7 +16,7 @@ var Drawing = require('../../components/drawing'); var Fx = require('../../components/fx'); var Plots = require('../plots'); var Axes = require('../cartesian/axes'); -var getAutoRange = require('../cartesian/autorange').getAutoRange; +const { concatExtremes, getAutoRange, makePadFn } = require('../cartesian/autorange'); var dragElement = require('../../components/dragelement'); var prepSelect = require('../../components/selections').prepSelect; var clearOutline = require('../../components/selections').clearOutline; @@ -26,7 +26,7 @@ var createGeoZoom = require('./zoom'); var constants = require('./constants'); var geoUtils = require('../../lib/geo_location_utils'); -const { getFitboundsLonRange, unwrapLonRange } = geoUtils; +const { unwrapLonRange } = geoUtils; var topojsonUtils = require('../../lib/topojson_utils'); var topojsonFeature = require('topojson-client').feature; @@ -237,51 +237,40 @@ proto.updateProjection = function (geoCalcData, fullLayout) { axLon.range = getAutoRange(gd, axLon); axLat.range = getAutoRange(gd, axLat); - // 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. - if (!this.hasChoropleth && geoLayout.fitbounds === 'locations') { - var lons = []; - var hasLocationData = false; - - for (var i = 0; i < geoCalcData.length; i++) { - var calcTrace = geoCalcData[i]; - var fitTrace = calcTrace[0].trace; - - // only visible traces contribute to the autorange above - if (fitTrace.visible !== true) continue; - if (fitTrace.locations?.length) { - hasLocationData = true; - break; - } - for (var j = 0; j < calcTrace.length; j++) { - var lonlat = calcTrace[j].lonlat; - if (lonlat) lons.push(lonlat[0]); - } - } + // Min/maxing the per-trace ranges above breaks when data crosses the + // antimeridian, since `computeBbox` unwraps an east edge past 180°. Bounding + // every coordinate at once lets `geoBounds` pick the compact range instead. + const fitCoordParts = []; - if (!hasLocationData) { - var fitLonRange = getFitboundsLonRange(lons); - if (fitLonRange) { - // getFitboundsLonRange returns a tight [min, max]. getAutoRange - // pads the naive range (for marker size and the standard - // margin), so scale that padding to the narrower crossing range - // and apply it, keeping markers off the frame edge as on any - // other fitbounds map. The padding is symmetric, so the - // mid-longitude the projection centers on is unchanged. - var lonDataSpan = Lib.aggNums(Math.max, null, lons) - Lib.aggNums(Math.min, null, lons); - var lonPad = - lonDataSpan > 0 - ? (((axLon.range[1] - axLon.range[0] - lonDataSpan) / 2) * - (fitLonRange[1] - fitLonRange[0])) / - lonDataSpan - : 0; - axLon.range = [fitLonRange[0] - lonPad, fitLonRange[1] + lonPad]; - } - } + for (const calcTrace of geoCalcData) { + const fitTrace = calcTrace[0].trace; + if (fitTrace.visible !== true) continue; + + if (fitTrace._module.fitCoords) fitCoordParts.push(fitTrace._module.fitCoords(calcTrace, geoLayout)); + } + + // Get the extents in the same manner as getAutoRange + const lonExtremes = concatExtremes(gd, axLon); + const lonDataMin = lonExtremes.min.reduce((min, { val }) => Math.min(min, val), Infinity); + const lonDataMax = lonExtremes.max.reduce((max, { val }) => Math.max(max, val), -Infinity); + const lonDataSpan = lonDataMax - lonDataMin; + const fitBbox = geoUtils.boundsOfCoords(fitCoordParts.flat()); + const [fitWest, , fitEast] = fitBbox || []; + const fitSpan = fitEast - fitWest; + const useFit = Boolean(fitBbox) && (lonDataSpan > 360 || (lonDataSpan < 360 && fitSpan < lonDataSpan)); + + // Add padding in the same manner as getAutoRange. Ideally this could use an + // underlying helper function, but that doesn't exist yet so we handle it like this. + if (useFit) { + const getPadMin = makePadFn(fullLayout, axLon, 0); + const getPadMax = makePadFn(fullLayout, axLon, 1); + const padMin = lonExtremes.min.reduce((max, pt) => Math.max(max, getPadMin(pt)), 0); + const padMax = lonExtremes.max.reduce((max, pt) => Math.max(max, getPadMax(pt)), 0); + const usable = axLon._length - padMin - padMax; + const paddedSpan = usable > axLon._length / 10 ? (fitSpan * axLon._length) / usable : fitSpan; + const fitMid = (fitWest + fitEast) / 2; + + axLon.range = [fitMid - paddedSpan / 2, fitMid + paddedSpan / 2]; } var midLon = (axLon.range[0] + axLon.range[1]) / 2; diff --git a/src/traces/choropleth/index.js b/src/traces/choropleth/index.js index 82702e80add..0ae319f3d23 100644 --- a/src/traces/choropleth/index.js +++ b/src/traces/choropleth/index.js @@ -6,6 +6,7 @@ module.exports = { colorbar: require('../heatmap/colorbar'), calc: require('./calc'), calcGeoJSON: require('./plot').calcGeoJSON, + fitCoords: require('./plot').fitCoords, plot: require('./plot').plot, style: require('./style').style, styleOnSelect: require('./style').styleOnSelect, diff --git a/src/traces/choropleth/plot.js b/src/traces/choropleth/plot.js index d9ef853b5c0..3421a8691d5 100644 --- a/src/traces/choropleth/plot.js +++ b/src/traces/choropleth/plot.js @@ -38,13 +38,9 @@ function calcGeoJSON(calcTrace, fullLayout) { ? 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; + // A falsy result (another fitbounds mode, or a Sphere/malformed/empty geojson) + // falls back to per-feature bounds, similar to `fitbounds === 'locations'`. + const bboxGeojson = geoUtils.fitGeojsonBbox(trace, geoLayout); var lonArray = []; var latArray = []; @@ -85,7 +81,29 @@ function calcGeoJSON(calcTrace, fullLayout) { trace._extremes.lat = findExtremes(geoLayout.lataxis._ax, latArray, opts); } +/** + * Append the coordinates this trace contributes to a subplot-wide `fitbounds` + * bounding box. Keeping it all together allows for proper auto-fitting of + * geometry that crosses the antimeridian. + * + * @param {Array} calcTrace - calcdata for this trace + * @param {object} geoLayout - The subplot's `fullLayout` entry + * @return {Array} `[lon, lat]` pairs + */ +function fitCoords(calcTrace, geoLayout) { + const geojsonCoords = geoUtils.fitGeojsonCoords(calcTrace[0].trace, geoLayout); + if (geojsonCoords.length) return geojsonCoords; + + const parts = []; + for (const calcPt of calcTrace) { + if (calcPt.geojson) parts.push(geoUtils.coordsOf(calcPt.geojson)); + } + + return parts.flat(); +} + module.exports = { calcGeoJSON, + fitCoords, plot }; diff --git a/src/traces/scattergeo/index.js b/src/traces/scattergeo/index.js index 173e46ba89a..ea81e2c1c58 100644 --- a/src/traces/scattergeo/index.js +++ b/src/traces/scattergeo/index.js @@ -7,6 +7,7 @@ module.exports = { formatLabels: require('./format_labels'), calc: require('./calc'), calcGeoJSON: require('./plot').calcGeoJSON, + fitCoords: require('./plot').fitCoords, plot: require('./plot').plot, style: require('./style'), styleOnSelect: require('../scatter/style').styleOnSelect, diff --git a/src/traces/scattergeo/plot.js b/src/traces/scattergeo/plot.js index 4fdd96d8be4..d5e54d38a17 100644 --- a/src/traces/scattergeo/plot.js +++ b/src/traces/scattergeo/plot.js @@ -100,11 +100,10 @@ function calcGeoJSON(calcTrace, fullLayout) { var opts = { padded: true }; var lonArray; var latArray; - - const bboxGeojson = - geoLayout.fitbounds === 'geojson' && trace.locationmode === 'geojson-id' - ? geoUtils.computeBbox(geoUtils.getTraceGeojson(trace)) - : null; + + // A falsy result (another fitbounds mode, or a Sphere/malformed/empty geojson) + // falls back to the plotted points, similar to `fitbounds === 'locations'`. + const bboxGeojson = geoUtils.fitGeojsonBbox(trace, geoLayout); if (bboxGeojson) { const [west, south, east, north] = bboxGeojson; @@ -126,7 +125,29 @@ function calcGeoJSON(calcTrace, fullLayout) { trace._extremes.lat = findExtremes(geoLayout.lataxis._ax, latArray, opts); } +/** + * Append the coordinates this trace contributes to a subplot-wide `fitbounds` + * bounding box. Keeping it all together allows for proper auto-fitting of + * geometry that crosses the antimeridian. + * + * @param {Array} calcTrace - calcdata for this trace + * @param {object} geoLayout - The subplot's `fullLayout` entry + * @return {Array} `[lon, lat]` pairs + */ +function fitCoords(calcTrace, geoLayout) { + const geojsonCoords = geoUtils.fitGeojsonCoords(calcTrace[0].trace, geoLayout); + if (geojsonCoords.length) return geojsonCoords; + + const coords = []; + for (const calcPt of calcTrace) { + if (calcPt.lonlat && isFinite(calcPt.lonlat[0])) coords.push(calcPt.lonlat); + } + + return coords; +} + module.exports = { - calcGeoJSON: calcGeoJSON, - plot: plot + calcGeoJSON, + fitCoords, + plot }; diff --git a/test/image/baselines/canada_geo_projections.png b/test/image/baselines/canada_geo_projections.png index bd58e72343e..d47b7748ffd 100644 Binary files a/test/image/baselines/canada_geo_projections.png and b/test/image/baselines/canada_geo_projections.png differ diff --git a/test/image/baselines/geo_country-names.png b/test/image/baselines/geo_country-names.png index 56ecabb58e4..9c8018a513e 100644 Binary files a/test/image/baselines/geo_country-names.png and b/test/image/baselines/geo_country-names.png differ diff --git a/test/image/baselines/geo_fitbounds-locations.png b/test/image/baselines/geo_fitbounds-locations.png index cf1eb7cf9ad..8327813832f 100644 Binary files a/test/image/baselines/geo_fitbounds-locations.png and b/test/image/baselines/geo_fitbounds-locations.png differ diff --git a/test/image/baselines/geo_point-selection.png b/test/image/baselines/geo_point-selection.png index 783a361907e..0857117d1f9 100644 Binary files a/test/image/baselines/geo_point-selection.png and b/test/image/baselines/geo_point-selection.png differ diff --git a/test/image/baselines/various_geo_projections.png b/test/image/baselines/various_geo_projections.png index d06637f3bfe..0374713871c 100644 Binary files a/test/image/baselines/various_geo_projections.png and b/test/image/baselines/various_geo_projections.png differ diff --git a/test/image/mocks/geo_country-names.json b/test/image/mocks/geo_country-names.json index f846380b835..bf41fd1f276 100644 --- a/test/image/mocks/geo_country-names.json +++ b/test/image/mocks/geo_country-names.json @@ -1012,12 +1012,14 @@ "geo": { "projection": { "type": "robinson" - } + }, + "fitbounds": false }, "geo2": { "projection": { "type": "robinson" - } + }, + "fitbounds": false }, "showlegend": false } diff --git a/test/image/mocks/various_geo_projections.json b/test/image/mocks/various_geo_projections.json index 117e815fe57..71a4815a7a7 100644 --- a/test/image/mocks/various_geo_projections.json +++ b/test/image/mocks/various_geo_projections.json @@ -33,8 +33,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "aitoff", @@ -69,8 +70,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "albers", @@ -105,8 +107,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "albers usa", @@ -142,9 +145,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locationmode": "USA-states", - "locations": ["TX", "WA", "NY", "AK"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-96.6], + "lat": [38.7] }, { "name": "august", @@ -179,8 +182,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "azimuthal equal area", @@ -215,8 +219,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "azimuthal equidistant", @@ -251,8 +256,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "baker", @@ -287,8 +293,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "bertin1953", @@ -323,8 +330,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "boggs", @@ -359,8 +367,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "bonne", @@ -395,8 +404,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "bottomley", @@ -431,8 +441,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "bromley", @@ -467,8 +478,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "collignon", @@ -503,8 +515,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "conic conformal", @@ -539,8 +552,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "conic equal area", @@ -575,8 +589,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "conic equidistant", @@ -611,8 +626,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "craig", @@ -647,8 +663,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [0], + "lat": [0] }, { "name": "craster", @@ -683,8 +700,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "cylindrical equal area", @@ -719,8 +737,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "cylindrical stereographic", @@ -755,8 +774,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "eckert1", @@ -791,8 +811,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "eckert2", @@ -827,8 +848,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "eckert3", @@ -863,8 +885,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "eckert4", @@ -899,8 +922,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "eckert5", @@ -935,8 +959,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "eckert6", @@ -971,8 +996,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "eisenlohr", @@ -1007,8 +1033,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "equal earth", @@ -1043,8 +1070,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "equirectangular", @@ -1079,8 +1107,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "fahey", @@ -1115,8 +1144,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "foucaut", @@ -1151,8 +1181,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "foucaut sinusoidal", @@ -1187,8 +1218,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "ginzburg4", @@ -1223,8 +1255,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "ginzburg5", @@ -1259,8 +1292,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "ginzburg6", @@ -1295,8 +1329,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "ginzburg8", @@ -1331,8 +1366,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "ginzburg9", @@ -1367,8 +1403,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "gnomonic", @@ -1403,8 +1440,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "gringorten", @@ -1439,8 +1477,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "gringorten quincuncial", @@ -1475,8 +1514,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "guyou", @@ -1511,8 +1551,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "hammer", @@ -1547,8 +1588,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "hill", @@ -1583,8 +1625,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "homolosine", @@ -1619,8 +1662,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "hufnagel", @@ -1655,8 +1699,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "hyperelliptical", @@ -1691,8 +1736,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "kavrayskiy7", @@ -1727,8 +1773,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "lagrange", @@ -1763,8 +1810,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "larrivee", @@ -1799,8 +1847,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "laskowski", @@ -1835,8 +1884,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "loximuthal", @@ -1871,8 +1921,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "mercator", @@ -1907,8 +1958,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "miller", @@ -1943,8 +1995,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "mollweide", @@ -1979,8 +2032,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "mt flat polar parabolic", @@ -2015,8 +2069,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "mt flat polar quartic", @@ -2051,8 +2106,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "mt flat polar sinusoidal", @@ -2087,8 +2143,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "natural earth", @@ -2123,8 +2180,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "natural earth1", @@ -2159,8 +2217,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "natural earth2", @@ -2195,8 +2254,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "nell hammer", @@ -2231,8 +2291,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "nicolosi", @@ -2267,8 +2328,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "orthographic", @@ -2303,8 +2365,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "patterson", @@ -2339,8 +2402,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "peirce quincuncial", @@ -2375,8 +2439,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [0], + "lat": [0] }, { "name": "polyconic", @@ -2411,8 +2476,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "rectangular polyconic", @@ -2447,8 +2513,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "robinson", @@ -2483,8 +2550,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "satellite", @@ -2519,8 +2587,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [0], + "lat": [0] }, { "name": "sinu mollweide", @@ -2555,8 +2624,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "sinusoidal", @@ -2591,8 +2661,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "stereographic", @@ -2627,8 +2698,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "times", @@ -2663,8 +2735,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "transverse mercator", @@ -2699,8 +2772,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "van der grinten", @@ -2735,8 +2809,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "van der grinten2", @@ -2771,8 +2846,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "van der grinten3", @@ -2807,8 +2883,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "van der grinten4", @@ -2843,8 +2920,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "wagner4", @@ -2879,8 +2957,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "wagner6", @@ -2915,8 +2994,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "wiechel", @@ -2951,8 +3031,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "winkel tripel", @@ -2987,8 +3068,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] }, { "name": "winkel3", @@ -3023,8 +3105,9 @@ "color": "darkblue", "family": "Gravitas One, cursive" }, - "locations": ["GHA", "ARG", "AUS", "CAN"], - "hoverinfo": "skip" + "hoverinfo": "skip", + "lon": [-114.39], + "lat": [14.06] } ], "layout": { diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index 2f38d4a2b3f..a4f1044c68f 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -101,6 +101,118 @@ describe('Test geo fitbounds with antimeridian-straddling points', function () { }); }); +describe('Test geo fitbounds with antimeridian-straddling locations', () => { + let gd; + + beforeEach(() => { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + const _plot = (traces) => + Plotly.newPlot(gd, traces, { + geo: { fitbounds: 'locations', projection: { type: 'equirectangular' } }, + width: 700, + height: 500 + }); + + const _choropleth = (locations) => ({ + type: 'choropleth', + locationmode: 'ISO-3', + locations, + z: locations.map((_, i) => i), + showscale: false + }); + + const _lonRange = () => gd._fullLayout.geo.lonaxis._ax.range; + + // 110m world bboxes, in the [west, east] form computeBbox returns: + // USA [172.63, 293.03] (Aleutians unwrapped past 180), CAN [-141.00, -52.64], + // MEX [-117.12, -86.75]. Bounding every coordinate together gives [172.63, 307.36], + // a 134.7deg span; min/maxing those endpoints gives 434deg and a world view. + const _assertNorthAmericaAcrossThePacific = (msg) => { + const lonRange = _lonRange(); + const lonSpan = lonRange[1] - lonRange[0]; + + expect(lonRange[0]).toBeLessThan(172.63, msg + ': west edge'); + expect(lonRange[1]).toBeGreaterThan(307.35, msg + ': east edge (past 180)'); + expect(lonSpan).toBeGreaterThan(134.7, msg + ': span covers the bounds'); + expect(lonSpan).toBeLessThan(160, msg + ': span stays near the bounds'); + // rotated to the mid-longitude of those bounds (~240deg, i.e. the US west + // coast), not to the naive mid (~76deg, in the Indian Ocean) + expect(gd._fullLayout.geo._subplot.projection.rotate()[0]).toBeCloseTo(-239.99, 1, msg + ': rotation'); + }; + + it('frames locations mixing an antimeridian-crossing feature with normal ones', (done) => { + _plot([_choropleth(['CAN', 'USA', 'MEX'])]) + .then(() => { + // updateProjection reaches this through the registered trace module, + // not through ./plot, so index.js has to re-export it + expect(typeof gd._fullData[0]._module.fitCoords).toBe('function'); + + _assertNorthAmericaAcrossThePacific('choropleth'); + // North America top to bottom: MEX south (14.5) to CAN north (83.1) + const latRange = gd._fullLayout.geo.lataxis._ax.range; + expect(latRange[0]).toBeLessThan(14.6); + expect(latRange[1]).toBeGreaterThan(83.1); + }) + .then(done, done.fail); + }); + + it('combines locations across traces on the same subplot', (done) => { + _plot([_choropleth(['USA']), _choropleth(['CAN', 'MEX'])]) + .then(() => { + _assertNorthAmericaAcrossThePacific('two choropleth traces'); + }) + .then(done, done.fail); + }); + + it('leaves a lone antimeridian-crossing location framed from its west edge', (done) => { + _plot([_choropleth(['USA'])]) + .then(() => { + const lonRange = _lonRange(); + // USA alone legitimately spans [172.63, 293.03], Aleutians included + expect(lonRange[0]).toBeLessThan(172.63); + expect(lonRange[1]).toBeGreaterThan(293.03); + expect(lonRange[1] - lonRange[0]).toBeLessThan(145); + }) + .then(done, done.fail); + }); + + it('keeps the naive framing for locations that do not cross the antimeridian', (done) => { + _plot([_choropleth(['CAN', 'MEX'])]) + .then(() => { + const lonRange = _lonRange(); + // CAN [-141.00, -52.64] and MEX both sit west of the antimeridian + expect(lonRange[0]).toBeLessThan(-141); + expect(lonRange[1]).toBeGreaterThan(-52.64); + expect(lonRange[1] - lonRange[0]).toBeLessThan(110); + }) + .then(done, done.fail); + }); + + it('compacts scattergeo *locations* centroids that straddle the antimeridian', (done) => { + // centroids: FJI [177.95, -17.84], USA [-99.11, 39.52] - 277deg apart the + // naive way, 83deg across the Pacific + _plot([ + { + type: 'scattergeo', + mode: 'markers', + locationmode: 'ISO-3', + locations: ['FJI', 'USA'] + } + ]) + .then(() => { + const lonRange = _lonRange(); + expect(lonRange[0]).toBeLessThan(177.95); + expect(lonRange[1]).toBeGreaterThan(260.89); + expect(lonRange[1] - lonRange[0]).toBeLessThan(110); + }) + .then(done, done.fail); + }); +}); + describe('Test Geo layout defaults', function () { var layoutAttributes = Geo.layoutAttributes; // Tests here were written against `fitbounds` defaulting to `false`. Shim @@ -2670,9 +2782,13 @@ describe('Test geo zoom/pan/drag interactions:', function () { delete fig.layout.geo.projection.rotation; fig.layout.geo.fitbounds = 'locations'; + // The mock's USA bbox is unwrapped past 180 for the Aleutians, so the fit + // bounds every coordinate together: [-4.77, 307.36] (a 312deg span centered + // on 151.29), not the 434deg endpoint min/max it would get + // from mixing the wrapped and unwrapped bboxes. newPlot(fig) .then(function () { - _assert('base', [[null, null], null], [[-76.014, -19.735], 160], undefined); + _assert('base', [[null, null], null], [[-151.292, -19.735], 160], undefined); return drag({ path: [ [250, 250], @@ -2684,8 +2800,8 @@ describe('Test geo zoom/pan/drag interactions:', function () { .then(function () { _assert( 'after east-west drag', - [[55.99, 21.103], 1], - [[-55.99, -21.103], 160], + [[131.268, 21.103], 1], + [[-131.268, -21.103], 160], [ 'geo.projection.rotation.lon', 'geo.projection.rotation.lat', @@ -2698,8 +2814,8 @@ describe('Test geo zoom/pan/drag interactions:', function () { .then(function () { _assert( 'after scroll', - [[58.694, 18.759], 1.1488], - [[-58.694, -18.759], 183.818], + [[133.972, 18.759], 1.1488], + [[-133.972, -18.759], 183.818], ['geo.projection.rotation.lon', 'geo.projection.rotation.lat', 'geo.projection.scale'] ); return Plotly.relayout(gd, 'geo.showocean', false); @@ -2707,14 +2823,14 @@ describe('Test geo zoom/pan/drag interactions:', function () { .then(function () { _assert( 'after some relayout call that causes a replot', - [[58.694, 18.759], 1.1488], - [[-58.694, -18.759], 183.818], + [[133.972, 18.759], 1.1488], + [[-133.972, -18.759], 183.818], ['geo.showocean'] ); return dblClick([350, 250]); }) .then(function () { - _assert('after double click', [[null, null], null], [[-76.014, -19.8], 160], 'dblclick'); + _assert('after double click', [[null, null], null], [[-151.292, -19.8], 160], 'dblclick'); }) .then(done, done.fail); }); diff --git a/test/jasmine/tests/lib_geo_location_utils_test.js b/test/jasmine/tests/lib_geo_location_utils_test.js index b2c486ed81a..ec13baaa22d 100644 --- a/test/jasmine/tests/lib_geo_location_utils_test.js +++ b/test/jasmine/tests/lib_geo_location_utils_test.js @@ -1,31 +1,97 @@ const { + boundsOfCoords, computeBbox, - getFitboundsLonRange, + coordsOf, + fitGeojsonBbox, + fitGeojsonCoords, unwrapLonRange, doesCrossAntiMeridian } = require('../../../src/lib/geo_location_utils'); -describe('Test geo_location_utils.getFitboundsLonRange', () => { - it('returns the compact crossing range when point data straddles the antimeridian', () => { - expect(getFitboundsLonRange([131.8855, -179])).toEqual([131.8855, 181]); - expect(getFitboundsLonRange([170, 175, -170])).toEqual([170, 190]); +describe('Test geo_location_utils.coordsOf', () => { + it('returns every coordinate in the object', () => { + expect(coordsOf({ type: 'Point', coordinates: [10, 0] })).toEqual([[10, 0]]); + expect(coordsOf({ type: 'MultiPoint', coordinates: [[20, 1], [30, 2]] })).toEqual([[20, 1], [30, 2]]); }); - it('keeps the naive range (null) when the data does not straddle the antimeridian', () => { - expect(getFitboundsLonRange([131.8855, 179])).toBe(null); - expect(getFitboundsLonRange([-10, 0, 20])).toBe(null); + it('returns an empty array for input with no extractable coordinates', () => { + expect(coordsOf({ type: 'Sphere' })).toEqual([]); + expect(coordsOf({ type: 'FeatureCollection', features: [] })).toEqual([]); + expect(coordsOf(null)).toEqual([]); + expect(coordsOf(undefined)).toEqual([]); + expect(coordsOf({})).toEqual([]); + }); +}); + +describe('Test geo_location_utils.boundsOfCoords', () => { + it('bounds several objects together, finding the compact crossing range', () => { + // separately these bbox to [172.6, 173.3] and [-141, -52.6]; min/maxing those + // endpoints spans 314deg, while bounding the coordinates together spans 135deg + const coords = [ + coordsOf({ type: 'MultiPoint', coordinates: [[172.6, 52], [173.3, 53]] }), + coordsOf({ type: 'MultiPoint', coordinates: [[-141, 60], [-52.6, 47]] }) + ].flat(); + + const [west, , east] = boundsOfCoords(coords); + expect(west).toBeCloseTo(172.6, 6); + expect(east).toBeCloseTo(307.4, 6); + }); + + it('agrees with computeBbox, which is defined in terms of it', () => { + const fc = { + type: 'FeatureCollection', + features: [ + { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [172.6, 52] } }, + { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [-52.6, 47] } } + ] + }; + + expect(boundsOfCoords(coordsOf(fc))).toEqual(computeBbox(fc)); + }); + + it('returns null when there are no coordinates', () => { + expect(boundsOfCoords([])).toBe(null); + }); +}); + +describe('Test geo_location_utils.fitGeojsonCoords / fitGeojsonBbox', () => { + // straddles the antimeridian: bounding both points together spans 135deg + const crossing = { type: 'MultiPoint', coordinates: [[172.6, 52], [-52.6, 47]] }; + const trace = (geojson, locationmode = 'geojson-id') => ({ geojson, locationmode }); + const geojsonFit = { fitbounds: 'geojson' }; + + it('returns the whole geojson coordinates in geojson fitbounds mode', () => { + expect(fitGeojsonCoords(trace(crossing), geojsonFit)).toEqual([[172.6, 52], [-52.6, 47]]); + }); + + it('bounds them across the antimeridian, east past 180', () => { + const [west, , east] = fitGeojsonBbox(trace(crossing), geojsonFit); + + expect(west).toBeCloseTo(172.6, 6); + expect(east).toBeCloseTo(307.4, 6); + }); + + it('declines for any other fitbounds mode or locationmode', () => { + expect(fitGeojsonCoords(trace(crossing), { fitbounds: 'locations' })).toEqual([]); + expect(fitGeojsonCoords(trace(crossing), { fitbounds: false })).toEqual([]); + expect(fitGeojsonCoords(trace(crossing, 'ISO-3'), geojsonFit)).toEqual([]); + + expect(fitGeojsonBbox(trace(crossing), { fitbounds: 'locations' })).toBe(null); + expect(fitGeojsonBbox(trace(crossing, 'ISO-3'), geojsonFit)).toBe(null); }); - it('keeps the naive range (null) when the data spans the whole globe', () => { - const lons = []; - for (let lon = 0; lon <= 360; lon += 2.5) lons.push(lon); - expect(getFitboundsLonRange(lons)).toBe(null); + it('declines when the geojson has nothing extractable, so callers fall back', () => { + for (const geojson of [{ type: 'Sphere' }, { type: 'FeatureCollection', features: [] }]) { + expect(fitGeojsonCoords(trace(geojson), geojsonFit)).toEqual([]); + expect(fitGeojsonBbox(trace(geojson), geojsonFit)).toBe(null); + } }); - it('returns null when fewer than two finite longitudes are available', () => { - expect(getFitboundsLonRange([10])).toBe(null); - expect(getFitboundsLonRange([NaN, 5])).toBe(null); - expect(getFitboundsLonRange([])).toBe(null); + it('keeps the two in step — the bbox is the bounds of the coords', () => { + for (const geojson of [crossing, { type: 'Sphere' }]) { + const t = trace(geojson); + expect(fitGeojsonBbox(t, geojsonFit)).toEqual(boundsOfCoords(fitGeojsonCoords(t, geojsonFit))); + } }); });