diff --git a/draftlogs/7884_fix.md b/draftlogs/7884_fix.md new file mode 100644 index 00000000000..1d6815d45b4 --- /dev/null +++ b/draftlogs/7884_fix.md @@ -0,0 +1 @@ + - Fix `scattermap`, `densitymap` traces not showing all points by dynamically computing `center`, `zoom` values [[#7884](https://github.com/plotly/plotly.js/pull/7884)], with thanks to @palmerusaf and @DhruvGarg111 for the contributions! diff --git a/src/plots/map/constants.js b/src/plots/map/constants.js index 52117231fae..0ed6b0aab97 100644 --- a/src/plots/map/constants.js +++ b/src/plots/map/constants.js @@ -70,17 +70,15 @@ var styleValuesMap = sortObjectKeys(stylesMap); module.exports = { styleValueDflt: 'basic', - stylesMap: stylesMap, - styleValuesMap: styleValuesMap, - + stylesMap, + styleValuesMap, traceLayerPrefix: 'plotly-trace-layer-', layoutLayerPrefix: 'plotly-layout-layer-', - missingStyleErrorMsg: [ 'No valid maplibre style found, please set `map.style` to one of:', styleValuesMap.join(', '), 'or use a tile service.' ].join('\n'), - - mapOnErrorMsg: 'Map error.' + mapOnErrorMsg: 'Map error.', + fitBoundsPadding: 20 }; diff --git a/src/plots/map/get_map_fit_bounds.ts b/src/plots/map/get_map_fit_bounds.ts new file mode 100644 index 00000000000..8eb53f5b515 --- /dev/null +++ b/src/plots/map/get_map_fit_bounds.ts @@ -0,0 +1,76 @@ +'use strict'; + +import { getFitboundsLonRange } from '../../lib/geo_location_utils'; +import type { MapLayout, ScattermapData } from '../../types/generated/schema'; + +// Same shape as the user-facing `map.bounds` attribute, but with all fields required +type LonLatBox = Required>; + +// Minimal shape of the fullData entries this helper reads +interface FitBoundsTrace extends Pick { + // Tighten lat/lon to be more specific than default + lat?: ArrayLike; + lon?: ArrayLike; + // Broaden type since this could run against multiple trace types + type?: string; +} + +/** + * Compute a lon/lat bounding box from lonlat-bearing traces (`scattermap`, + * `densitymap`) on the given map subplot. + * + * Returns null when: + * - no fittable data exists on the subplot; + * - a location-based trace (`choroplethmap`) is present — those carry + * `locations`/`geojson`, not raw lon/lat, and need geojson bbox handling + * that isn't implemented here. + * + * @param fullData - The full data array (post supply-defaults) + * @param subplotId - e.g. `'map'`, `'map2'` + */ +export function getMapFitBounds(fullData: FitBoundsTrace[], subplotId: string): LonLatBox | null { + const validLons: number[] = []; + let minLat = Infinity; + let maxLat = -Infinity; + + for (const trace of fullData) { + if (trace.subplot !== subplotId || trace.visible !== true) continue; + + // choroplethmap traces carry locations/geojson, not raw lon/lat; bail + // out rather than frame around a subset of the subplot's data. + if (trace.type === 'choroplethmap') return null; + + const { lat, lon } = trace; + if (!lon || !lat) continue; + + const len = Math.min(lon.length, lat.length); + for (let j = 0; j < len; j++) { + const lo = lon[j]; + const la = lat[j]; + if (Number.isFinite(lo) && Number.isFinite(la)) { + validLons.push(lo); + if (la < minLat) minLat = la; + if (la > maxLat) maxLat = la; + } + } + } + + if (!validLons.length) return null; + + let west: number; + let east: number; + const lonRange = getFitboundsLonRange(validLons); + if (lonRange) { + west = lonRange[0]; + east = lonRange[1]; + } else { + west = Infinity; + east = -Infinity; + for (const lon of validLons) { + if (lon < west) west = lon; + if (lon > east) east = lon; + } + } + + return { west, east, south: minLat, north: maxLat }; +} diff --git a/src/plots/map/layout_defaults.js b/src/plots/map/layout_defaults.js index 5cb2531fa1c..1861526f94f 100644 --- a/src/plots/map/layout_defaults.js +++ b/src/plots/map/layout_defaults.js @@ -5,18 +5,19 @@ var Lib = require('../../lib'); var handleSubplotDefaults = require('../subplot_defaults'); var handleArrayContainerDefaults = require('../array_container_defaults'); var layoutAttributes = require('./layout_attributes'); - +const { getMapFitBounds } = require('./get_map_fit_bounds'); module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { handleSubplotDefaults(layoutIn, layoutOut, fullData, { type: 'map', attributes: layoutAttributes, - handleDefaults: handleDefaults, - partition: 'y' + handleDefaults, + partition: 'y', + fullData }); }; -function handleDefaults(containerIn, containerOut, coerce) { +function handleDefaults(containerIn, containerOut, coerce, opts) { coerce('style'); coerce('center.lon'); coerce('center.lat'); @@ -28,12 +29,7 @@ function handleDefaults(containerIn, containerOut, coerce) { var east = coerce('bounds.east'); var south = coerce('bounds.south'); var north = coerce('bounds.north'); - if( - west === undefined || - east === undefined || - south === undefined || - north === undefined - ) { + if (west === undefined || east === undefined || south === undefined || north === undefined) { delete containerOut.bounds; } @@ -42,6 +38,12 @@ function handleDefaults(containerIn, containerOut, coerce) { handleItemDefaults: handleLayerDefaults }); + // Auto-frame the initial view to the data + if (containerIn.center === undefined && containerIn.zoom === undefined) { + const fitBounds = getMapFitBounds(opts.fullData, opts.id); + if (fitBounds) containerOut._fitBounds = fitBounds; + } + // copy ref to input container to update 'center' and 'zoom' on map move containerOut._input = containerIn; } @@ -52,27 +54,27 @@ function handleLayerDefaults(layerIn, layerOut) { } var visible = coerce('visible'); - if(visible) { + if (visible) { var sourceType = coerce('sourcetype'); var mustBeRasterLayer = sourceType === 'raster' || sourceType === 'image'; coerce('source'); coerce('sourceattribution'); - if(sourceType === 'vector') { + if (sourceType === 'vector') { coerce('sourcelayer'); } - if(sourceType === 'image') { + if (sourceType === 'image') { coerce('coordinates'); } var typeDflt; - if(mustBeRasterLayer) typeDflt = 'raster'; + if (mustBeRasterLayer) typeDflt = 'raster'; var type = coerce('type', typeDflt); - if(mustBeRasterLayer && type !== 'raster') { + if (mustBeRasterLayer && type !== 'raster') { type = layerOut.type = 'raster'; Lib.log('Source types *raster* and *image* must drawn *raster* layer type.'); } @@ -83,20 +85,20 @@ function handleLayerDefaults(layerIn, layerOut) { coerce('minzoom'); coerce('maxzoom'); - if(type === 'circle') { + if (type === 'circle') { coerce('circle.radius'); } - if(type === 'line') { + if (type === 'line') { coerce('line.width'); coerce('line.dash'); } - if(type === 'fill') { + if (type === 'fill') { coerce('fill.outlinecolor'); } - if(type === 'symbol') { + if (type === 'symbol') { coerce('symbol.icon'); coerce('symbol.iconsize'); @@ -105,7 +107,7 @@ function handleLayerDefaults(layerIn, layerOut) { noFontVariant: true, noFontShadow: true, noFontLineposition: true, - noFontTextcase: true, + noFontTextcase: true }); coerce('symbol.textposition'); coerce('symbol.placement'); diff --git a/src/plots/map/map.js b/src/plots/map/map.js index ee718e2b85f..63a0d77e6f1 100644 --- a/src/plots/map/map.js +++ b/src/plots/map/map.js @@ -52,17 +52,17 @@ function Map(gd, id) { var proto = Map.prototype; -proto.plot = function(calcData, fullLayout, promises) { +proto.plot = function (calcData, fullLayout, promises) { var self = this; var promise; - if(!self.map) { - promise = new Promise(function(resolve, reject) { + if (!self.map) { + promise = new Promise(function (resolve, reject) { self.createMap(calcData, fullLayout, resolve, reject); }); } else { - promise = new Promise(function(resolve, reject) { + promise = new Promise(function (resolve, reject) { self.updateMap(calcData, fullLayout, resolve, reject); }); } @@ -70,91 +70,128 @@ proto.plot = function(calcData, fullLayout, promises) { promises.push(promise); }; -proto.createMap = function(calcData, fullLayout, resolve, reject) { +proto.createMap = function (calcData, fullLayout, resolve, reject) { var self = this; var opts = fullLayout[self.id]; // store style id and URL or object - var styleObj = self.styleObj = getStyleObj(opts.style); - + var styleObj = (self.styleObj = getStyleObj(opts.style)); var bounds = opts.bounds; - var maxBounds = bounds ? [[bounds.west, bounds.south], [bounds.east, bounds.north]] : null; - - // create the map! - var map = self.map = new maplibregl.Map({ + var maxBounds = bounds + ? [ + [bounds.west, bounds.south], + [bounds.east, bounds.north] + ] + : null; + + const mapOptions = { container: self.div, - style: styleObj.style, center: convertCenter(opts.center), zoom: opts.zoom, bearing: opts.bearing, pitch: opts.pitch, maxBounds: maxBounds, - interactive: !self.isStatic, preserveDrawingBuffer: self.isStatic, - doubleClickZoom: false, boxZoom: false, - attributionControl: false - }) - .addControl(new maplibregl.AttributionControl({ - compact: true - })); + }; + + // Auto-frame the initial view if supplyLayoutDefaults computed a + // bounding box (i.e. the user didn't specify center/zoom) + const fitBounds = opts._fitBounds; + if (fitBounds) { + mapOptions.bounds = [ + [fitBounds.west, fitBounds.south], + [fitBounds.east, fitBounds.north] + ]; + mapOptions.fitBoundsOptions = { padding: constants.fitBoundsPadding }; + } + + // Create the map! + const map = (self.map = new maplibregl.Map(mapOptions).addControl( + new maplibregl.AttributionControl({ compact: true }) + )); var requestedIcons = {}; - map.on('styleimagemissing', function(e) { + map.on('styleimagemissing', function (e) { var id = e.id; - if(!requestedIcons[id] && /^[a-zA-Z0-9-]+$/.test(id)) { + if (!requestedIcons[id] && /^[a-zA-Z0-9-]+$/.test(id)) { requestedIcons[id] = true; var img = new Image(15, 15); - img.onload = function() { - map.addImage(id, img, {sdf: true}); + img.onload = function () { + map.addImage(id, img, { sdf: true }); }; img.crossOrigin = 'Anonymous'; - img.src = "https://cdn.jsdelivr.net/npm/@mapbox/maki@8.2.0/icons/" + id + '.svg'; + img.src = 'https://cdn.jsdelivr.net/npm/@mapbox/maki@8.2.0/icons/' + id + '.svg'; } }); - map.setTransformRequest(function(url) { - url = url.replace('https://fonts.openmaptiles.org/Open Sans Extrabold', 'https://fonts.openmaptiles.org/Open Sans Extra Bold'); - url = url.replace('https://tiles.basemaps.cartocdn.com/fonts/Open Sans Extrabold', 'https://fonts.openmaptiles.org/Open Sans Extra Bold'); - url = url.replace('https://fonts.openmaptiles.org/Open Sans Regular,Arial Unicode MS Regular', 'https://fonts.openmaptiles.org/Klokantech Noto Sans Regular'); + map.setTransformRequest(function (url) { + url = url.replace( + 'https://fonts.openmaptiles.org/Open Sans Extrabold', + 'https://fonts.openmaptiles.org/Open Sans Extra Bold' + ); + url = url.replace( + 'https://tiles.basemaps.cartocdn.com/fonts/Open Sans Extrabold', + 'https://fonts.openmaptiles.org/Open Sans Extra Bold' + ); + url = url.replace( + 'https://fonts.openmaptiles.org/Open Sans Regular,Arial Unicode MS Regular', + 'https://fonts.openmaptiles.org/Klokantech Noto Sans Regular' + ); return { url: url }; }); - // make sure canvas does not inherit left and top css map._canvas.style.left = '0px'; map._canvas.style.top = '0px'; self.rejectOnError(reject); - if(!self.isStatic) { + if (!self.isStatic) { self.initFx(calcData, fullLayout); } var promises = []; - promises.push(new Promise(function(resolve) { - map.once('load', resolve); - })); + promises.push( + new Promise(function (resolve) { + map.once('load', resolve); + }) + ); promises = promises.concat(geoUtils.fetchTraceGeoData(calcData)); - Promise.all(promises).then(function() { - self.fillBelowLookup(calcData, fullLayout); - self.updateData(calcData); - self.updateLayout(fullLayout); - self.resolveOnRender(resolve); - }).catch(reject); + Promise.all(promises) + .then(function () { + // Capture the auto-framed view so subsequent updateLayout/resetView + // calls don't reset to the schema defaults + if (fitBounds) { + const { center, zoom } = self.getView(); + opts._input.center = opts.center = center; + opts._input.zoom = opts.zoom = zoom; + self.viewInitial = { + center: Lib.extendFlat({}, center), + bearing: opts.bearing, + pitch: opts.pitch, + zoom + }; + } + self.fillBelowLookup(calcData, fullLayout); + self.updateData(calcData); + self.updateLayout(fullLayout); + self.resolveOnRender(resolve); + }) + .catch(reject); }; -proto.updateMap = function(calcData, fullLayout, resolve, reject) { +proto.updateMap = function (calcData, fullLayout, resolve, reject) { var self = this; var map = self.map; var opts = fullLayout[this.id]; @@ -164,7 +201,7 @@ proto.updateMap = function(calcData, fullLayout, resolve, reject) { var promises = []; var styleObj = getStyleObj(opts.style); - if(JSON.stringify(self.styleObj) !== JSON.stringify(styleObj)) { + if (JSON.stringify(self.styleObj) !== JSON.stringify(styleObj)) { self.styleObj = styleObj; map.setStyle(styleObj.style); @@ -172,53 +209,57 @@ proto.updateMap = function(calcData, fullLayout, resolve, reject) { // to avoid 'lost event' errors self.traceHash = {}; - promises.push(new Promise(function(resolve) { - map.once('styledata', resolve); - })); + promises.push( + new Promise(function (resolve) { + map.once('styledata', resolve); + }) + ); } promises = promises.concat(geoUtils.fetchTraceGeoData(calcData)); - Promise.all(promises).then(function() { - self.fillBelowLookup(calcData, fullLayout); - self.updateData(calcData); - self.updateLayout(fullLayout); - self.resolveOnRender(resolve); - }).catch(reject); + Promise.all(promises) + .then(function () { + self.fillBelowLookup(calcData, fullLayout); + self.updateData(calcData); + self.updateLayout(fullLayout); + self.resolveOnRender(resolve); + }) + .catch(reject); }; -proto.fillBelowLookup = function(calcData, fullLayout) { +proto.fillBelowLookup = function (calcData, fullLayout) { var opts = fullLayout[this.id]; var layers = opts.layers; var i, val; - var belowLookup = this.belowLookup = {}; + var belowLookup = (this.belowLookup = {}); var hasTraceAtTop = false; - for(i = 0; i < calcData.length; i++) { + for (i = 0; i < calcData.length; i++) { var trace = calcData[i][0].trace; var _module = trace._module; - if(typeof trace.below === 'string') { + if (typeof trace.below === 'string') { val = trace.below; - } else if(_module.getBelow) { + } else if (_module.getBelow) { // 'smart' default that depend the map's base layers val = _module.getBelow(trace, this); } - if(val === '') { + if (val === '') { hasTraceAtTop = true; } belowLookup['trace-' + trace.uid] = val || ''; } - for(i = 0; i < layers.length; i++) { + for (i = 0; i < layers.length; i++) { var item = layers[i]; - if(typeof item.below === 'string') { + if (typeof item.below === 'string') { val = item.below; - } else if(hasTraceAtTop) { + } else if (hasTraceAtTop) { // if one or more trace(s) set `below:''` and // layers[i].below is unset, // place layer below traces @@ -238,28 +279,28 @@ proto.fillBelowLookup = function(calcData, fullLayout) { var val2list = {}; var k, id; - for(k in belowLookup) { + for (k in belowLookup) { val = belowLookup[k]; - if(val2list[val]) { + if (val2list[val]) { val2list[val].push(k); } else { val2list[val] = [k]; } } - for(val in val2list) { + for (val in val2list) { var list = val2list[val]; - if(list.length > 1) { - for(i = 0; i < list.length; i++) { + if (list.length > 1) { + for (i = 0; i < list.length; i++) { k = list[i]; - if(k.indexOf('trace-') === 0) { + if (k.indexOf('trace-') === 0) { id = k.split('trace-')[1]; - if(this.traceHash[id]) { + if (this.traceHash[id]) { this.traceHash[id].below = null; } - } else if(k.indexOf('layout-') === 0) { + } else if (k.indexOf('layout-') === 0) { id = k.split('layout-')[1]; - if(this.layerList[id]) { + if (this.layerList[id]) { this.layerList[id].below = null; } } @@ -274,7 +315,7 @@ var traceType2orderIndex = { scattermap: 2 }; -proto.updateData = function(calcData) { +proto.updateData = function (calcData) { var traceHash = this.traceHash; var traceObj, trace, i, j; @@ -282,43 +323,39 @@ proto.updateData = function(calcData) { // in case traces with different `type` have the same // below value, but sorting we ensure that // e.g. choroplethmap traces will be below scattermap traces - var calcDataSorted = calcData.slice().sort(function(a, b) { - return ( - traceType2orderIndex[a[0].trace.type] - - traceType2orderIndex[b[0].trace.type] - ); + var calcDataSorted = calcData.slice().sort(function (a, b) { + return traceType2orderIndex[a[0].trace.type] - traceType2orderIndex[b[0].trace.type]; }); // update or create trace objects - for(i = 0; i < calcDataSorted.length; i++) { + for (i = 0; i < calcDataSorted.length; i++) { var calcTrace = calcDataSorted[i]; trace = calcTrace[0].trace; traceObj = traceHash[trace.uid]; var didUpdate = false; - if(traceObj) { - if(traceObj.type === trace.type) { + if (traceObj) { + if (traceObj.type === trace.type) { traceObj.update(calcTrace); didUpdate = true; } else { traceObj.dispose(); } } - if(!didUpdate && trace._module) { + if (!didUpdate && trace._module) { traceHash[trace.uid] = trace._module.plot(this, calcTrace); } } // remove empty trace objects var ids = Object.keys(traceHash); - idLoop: - for(i = 0; i < ids.length; i++) { + idLoop: for (i = 0; i < ids.length; i++) { var id = ids[i]; - for(j = 0; j < calcData.length; j++) { + for (j = 0; j < calcData.length; j++) { trace = calcData[j][0].trace; - if(id === trace.uid) continue idLoop; + if (id === trace.uid) continue idLoop; } traceObj = traceHash[id]; @@ -327,11 +364,11 @@ proto.updateData = function(calcData) { } }; -proto.updateLayout = function(fullLayout) { +proto.updateLayout = function (fullLayout) { var map = this.map; var opts = fullLayout[this.id]; - if(!this.dragging && !this.wheeling) { + if (!this.dragging && !this.wheeling) { map.setCenter(convertCenter(opts.center)); map.setZoom(opts.zoom); map.setBearing(opts.bearing); @@ -343,18 +380,18 @@ proto.updateLayout = function(fullLayout) { this.updateFx(fullLayout); this.map.resize(); - if(this.gd._context._scrollZoom.map) { + if (this.gd._context._scrollZoom.map) { map.scrollZoom.enable(); } else { map.scrollZoom.disable(); } }; -proto.resolveOnRender = function(resolve) { +proto.resolveOnRender = function (resolve) { var map = this.map; map.on('render', function onRender() { - if(map.loaded()) { + if (map.loaded()) { map.off('render', onRender); // resolve at end of render loop // @@ -365,7 +402,7 @@ proto.resolveOnRender = function(resolve) { }); }; -proto.rejectOnError = function(reject) { +proto.rejectOnError = function (reject) { var map = this.map; function handler() { @@ -379,10 +416,10 @@ proto.rejectOnError = function(reject) { map.once('layer.error', handler); }; -proto.createFramework = function(fullLayout) { +proto.createFramework = function (fullLayout) { var self = this; - var div = self.div = document.createElement('div'); + var div = (self.div = document.createElement('div')); div.id = self.uid; div.style.position = 'absolute'; self.container.appendChild(div); @@ -390,11 +427,15 @@ proto.createFramework = function(fullLayout) { // create mock x/y axes for hover routine self.xaxis = { _id: 'x', - c2p: function(v) { return self.project(v).x; } + c2p: function (v) { + return self.project(v).x; + } }; self.yaxis = { _id: 'y', - c2p: function(v) { return self.project(v).y; } + c2p: function (v) { + return self.project(v).y; + } }; self.updateFramework(fullLayout); @@ -408,14 +449,14 @@ proto.createFramework = function(fullLayout) { Axes.setConvert(self.mockAxis, fullLayout); }; -proto.initFx = function(calcData, fullLayout) { +proto.initFx = function (calcData, fullLayout) { var self = this; var gd = self.gd; var map = self.map; // keep track of pan / zoom in user layout and emit relayout event - map.on('moveend', function(evt) { - if(!self.map) return; + map.on('moveend', function (evt) { + if (!self.map) return; var fullLayoutNow = gd._fullLayout; @@ -427,7 +468,7 @@ proto.initFx = function(calcData, fullLayout) { // mouse target (filtering out API calls) to not // duplicate 'plotly_relayout' events. - if(evt.originalEvent || self.wheeling) { + if (evt.originalEvent || self.wheeling) { var optsNow = fullLayoutNow[self.id]; Registry.call('_storeDirectGUIEdit', gd.layout, fullLayoutNow._preGUI, self.getViewEdits(optsNow)); @@ -438,35 +479,38 @@ proto.initFx = function(calcData, fullLayout) { optsNow._input.pitch = optsNow.pitch = viewNow.pitch; gd.emit('plotly_relayout', self.getViewEditsWithDerived(viewNow)); } - if(evt.originalEvent && evt.originalEvent.type === 'mouseup') { + if (evt.originalEvent && evt.originalEvent.type === 'mouseup') { self.dragging = false; - } else if(self.wheeling) { + } else if (self.wheeling) { self.wheeling = false; } - if(fullLayoutNow && fullLayoutNow._rehover) { + if (fullLayoutNow && fullLayoutNow._rehover) { fullLayoutNow._rehover(); } }); - map.on('wheel', function() { + map.on('wheel', function () { self.wheeling = true; }); - map.on('mousemove', function(evt) { + map.on('mousemove', function (evt) { var bb = self.div.getBoundingClientRect(); - var xy = [ - evt.originalEvent.offsetX, - evt.originalEvent.offsetY - ]; + var xy = [evt.originalEvent.offsetX, evt.originalEvent.offsetY]; - evt.target.getBoundingClientRect = function() { return bb; }; + evt.target.getBoundingClientRect = function () { + return bb; + }; - self.xaxis.p2c = function() { return map.unproject(xy).lng; }; - self.yaxis.p2c = function() { return map.unproject(xy).lat; }; + self.xaxis.p2c = function () { + return map.unproject(xy).lng; + }; + self.yaxis.p2c = function () { + return map.unproject(xy).lat; + }; - gd._fullLayout._rehover = function() { - if(gd._fullLayout._hoversubplot === self.id && gd._fullLayout[self.id]) { + gd._fullLayout._rehover = function () { + if (gd._fullLayout._hoversubplot === self.id && gd._fullLayout[self.id]) { Fx.hover(gd, evt, self.id); } }; @@ -479,13 +523,13 @@ proto.initFx = function(calcData, fullLayout) { Fx.loneUnhover(fullLayout._hoverlayer); } - map.on('dragstart', function() { + map.on('dragstart', function () { self.dragging = true; unhover(); }); map.on('zoomstart', unhover); - map.on('mouseout', function() { + map.on('mouseout', function () { gd._fullLayout._hoversubplot = null; }); @@ -497,7 +541,7 @@ proto.initFx = function(calcData, fullLayout) { map.on('drag', emitUpdate); map.on('zoom', emitUpdate); - map.on('dblclick', function() { + map.on('dblclick', function () { var optsNow = gd._fullLayout[self.id]; Registry.call('_storeDirectGUIEdit', gd.layout, gd._fullLayout._preGUI, self.getViewEdits(optsNow)); @@ -519,7 +563,7 @@ proto.initFx = function(calcData, fullLayout) { // define event handlers on map creation, to keep one ref per map, // so that map.on / map.off in updateFx works as expected - self.clearOutline = function() { + self.clearOutline = function () { clearSelectionsCache(self.dragOptions); clearOutline(self.dragOptions.gd); }; @@ -528,15 +572,15 @@ proto.initFx = function(calcData, fullLayout) { * Returns a click handler function that is supposed * to handle clicks in pan mode. */ - self.onClickInPanFn = function(dragOptions) { - return function(evt) { + self.onClickInPanFn = function (dragOptions) { + return function (evt) { var clickMode = gd._fullLayout.clickmode; - if(clickMode.indexOf('select') > -1) { + if (clickMode.indexOf('select') > -1) { selectOnClick(evt.originalEvent, gd, [self.xaxis], [self.yaxis], self.id, dragOptions); } - if(clickMode.indexOf('event') > -1) { + if (clickMode.indexOf('event') > -1) { // TODO: this does not support right-click. If we want to support it, we // would likely need to change map to use dragElement instead of straight // map event binding. Or perhaps better, make a simple wrapper with the @@ -548,12 +592,12 @@ proto.initFx = function(calcData, fullLayout) { }; }; -proto.updateFx = function(fullLayout) { +proto.updateFx = function (fullLayout) { var self = this; var map = self.map; var gd = self.gd; - if(self.isStatic) return; + if (self.isStatic) return; function invert(pxpy) { var obj = self.map.unproject(pxpy); @@ -563,15 +607,12 @@ proto.updateFx = function(fullLayout) { var dragMode = fullLayout.dragmode; var fillRangeItems; - fillRangeItems = function(eventData, poly) { - if(poly.isRect) { - var ranges = eventData.range = {}; - ranges[self.id] = [ - invert([poly.xmin, poly.ymin]), - invert([poly.xmax, poly.ymax]) - ]; + fillRangeItems = function (eventData, poly) { + if (poly.isRect) { + var ranges = (eventData.range = {}); + ranges[self.id] = [invert([poly.xmin, poly.ymin]), invert([poly.xmax, poly.ymax])]; } else { - var dataPts = eventData.lassoPoints = {}; + var dataPts = (eventData.lassoPoints = {}); dataPts[self.id] = poly.map(invert); } }; @@ -601,11 +642,11 @@ proto.updateFx = function(fullLayout) { // a new one. Otherwise multiple click handlers might // be registered resulting in unwanted behavior. map.off('click', self.onClickInPanHandler); - if(selectMode(dragMode) || drawMode(dragMode)) { + if (selectMode(dragMode) || drawMode(dragMode)) { map.dragPan.disable(); map.on('zoomstart', self.clearOutline); - self.dragOptions.prepFn = function(e, startX, startY) { + self.dragOptions.prepFn = function (e, startX, startY) { prepSelect(e, startX, startY, self.dragOptions, dragMode); }; @@ -626,7 +667,7 @@ proto.updateFx = function(fullLayout) { } }; -proto.updateFramework = function(fullLayout) { +proto.updateFramework = function (fullLayout) { var domain = fullLayout[this.id].domain; var size = fullLayout._size; @@ -643,7 +684,7 @@ proto.updateFramework = function(fullLayout) { this.yaxis._length = size.h * (domain.y[1] - domain.y[0]); }; -proto.updateLayers = function(fullLayout) { +proto.updateLayers = function (fullLayout) { var opts = fullLayout[this.id]; var layers = opts.layers; var layerList = this.layerList; @@ -653,85 +694,87 @@ proto.updateLayers = function(fullLayout) { // don't try to be smart, // delete them all, and start all over. - if(layers.length !== layerList.length) { - for(i = 0; i < layerList.length; i++) { + if (layers.length !== layerList.length) { + for (i = 0; i < layerList.length; i++) { layerList[i].dispose(); } layerList = this.layerList = []; - for(i = 0; i < layers.length; i++) { + for (i = 0; i < layers.length; i++) { layerList.push(createMapLayer(this, i, layers[i])); } } else { - for(i = 0; i < layers.length; i++) { + for (i = 0; i < layers.length; i++) { layerList[i].update(layers[i]); } } }; -proto.destroy = function() { - if(this.map) { +proto.destroy = function () { + if (this.map) { this.map.remove(); this.map = null; this.container.removeChild(this.div); } }; -proto.toImage = function() { +proto.toImage = function () { this.map.stop(); return this.map.getCanvas().toDataURL(); }; // convenience wrapper to create set multiple layer // 'layout' or 'paint options at once. -proto.setOptions = function(id, methodName, opts) { - for(var k in opts) { +proto.setOptions = function (id, methodName, opts) { + for (var k in opts) { this.map[methodName](id, k, opts[k]); } }; -proto.getMapLayers = function() { +proto.getMapLayers = function () { return this.map.getStyle().layers; }; // convenience wrapper that first check in 'below' references // a layer that exist and then add the layer to the map, -proto.addLayer = function(opts, below) { +proto.addLayer = function (opts, below) { var map = this.map; - if(typeof below === 'string') { - if(below === '') { + if (typeof below === 'string') { + if (below === '') { map.addLayer(opts, below); return; } var mapLayers = this.getMapLayers(); - for(var i = 0; i < mapLayers.length; i++) { - if(below === mapLayers[i].id) { + for (var i = 0; i < mapLayers.length; i++) { + if (below === mapLayers[i].id) { map.addLayer(opts, below); return; } } - Lib.warn([ - 'Trying to add layer with *below* value', - below, - 'referencing a layer that does not exist', - 'or that does not yet exist.' - ].join(' ')); + Lib.warn( + [ + 'Trying to add layer with *below* value', + below, + 'referencing a layer that does not exist', + 'or that does not yet exist.' + ].join(' ') + ); } map.addLayer(opts); }; // convenience method to project a [lon, lat] array to pixel coords -proto.project = function(v) { +proto.project = function (v) { return this.map.project(new maplibregl.LngLat(v[0], v[1])); }; // get map's current view values in plotly.js notation -proto.getView = function() { +proto.getView = function () { var map = this.map; var mapCenter = map.getCenter(); var lon = mapCenter.lng; @@ -758,12 +801,12 @@ proto.getView = function() { }; }; -proto.getViewEdits = function(cont) { +proto.getViewEdits = function (cont) { var id = this.id; var keys = ['center', 'zoom', 'bearing', 'pitch']; var obj = {}; - for(var i = 0; i < keys.length; i++) { + for (var i = 0; i < keys.length; i++) { var k = keys[i]; obj[id + '.' + k] = cont[k]; } @@ -771,7 +814,7 @@ proto.getViewEdits = function(cont) { return obj; }; -proto.getViewEditsWithDerived = function(cont) { +proto.getViewEditsWithDerived = function (cont) { var id = this.id; var obj = this.getViewEdits(cont); obj[id + '._derived'] = cont._derived; @@ -781,13 +824,13 @@ proto.getViewEditsWithDerived = function(cont) { function getStyleObj(val) { var styleObj = {}; - if(Lib.isPlainObject(val)) { + if (Lib.isPlainObject(val)) { styleObj.id = val.id; styleObj.style = val; - } else if(typeof val === 'string') { + } else if (typeof val === 'string') { styleObj.id = val; - if(constants.stylesMap[val]) { + if (constants.stylesMap[val]) { styleObj.style = constants.stylesMap[val]; } else { styleObj.style = val; @@ -797,7 +840,7 @@ function getStyleObj(val) { styleObj.style = constants.stylesMap[constants.styleValueDflt]; } - styleObj.transition = {duration: 0, delay: 0}; + styleObj.transition = { duration: 0, delay: 0 }; return styleObj; } diff --git a/test/image/baselines/scattermap_dynamic_defaults.png b/test/image/baselines/scattermap_dynamic_defaults.png new file mode 100644 index 00000000000..7e86a490e72 Binary files /dev/null and b/test/image/baselines/scattermap_dynamic_defaults.png differ diff --git a/test/image/mocks/map_carto-style.json b/test/image/mocks/map_carto-style.json index 460a5df5ba0..0ef5f1ab9d2 100644 --- a/test/image/mocks/map_carto-style.json +++ b/test/image/mocks/map_carto-style.json @@ -25,11 +25,13 @@ "map": { "domain": { "row": 0, "column": 0 }, - "style": "carto-positron" + "style": "carto-positron", + "zoom": 1 }, "map2": { "domain": { "row": 0, "column": 1 }, - "style": "carto-darkmatter" + "style": "carto-darkmatter", + "zoom": 1 } } } diff --git a/test/image/mocks/map_carto-text.json b/test/image/mocks/map_carto-text.json index 72f334fb5d6..0734742ad84 100644 --- a/test/image/mocks/map_carto-text.json +++ b/test/image/mocks/map_carto-text.json @@ -30,11 +30,13 @@ "map": { "domain": { "row": 0, "column": 0 }, - "style": "carto-positron" + "style": "carto-positron", + "zoom": 1 }, "map2": { "domain": { "row": 0, "column": 1 }, - "style": "carto-darkmatter" + "style": "carto-darkmatter", + "zoom": 1 } } } diff --git a/test/image/mocks/map_density0.json b/test/image/mocks/map_density0.json index d9f69098571..2c6a9c7d20b 100644 --- a/test/image/mocks/map_density0.json +++ b/test/image/mocks/map_density0.json @@ -9,6 +9,7 @@ ], "layout": { "width": 600, - "height": 400 + "height": 400, + "map": { "zoom": 1 } } } diff --git a/test/image/mocks/map_white-bg-style.json b/test/image/mocks/map_white-bg-style.json index 3e76170a169..034ffc3f122 100644 --- a/test/image/mocks/map_white-bg-style.json +++ b/test/image/mocks/map_white-bg-style.json @@ -12,7 +12,8 @@ "height": 200, "margin": { "t": 0, "b": 0, "l": 0, "r": 0 }, "map": { - "style": "white-bg" + "style": "white-bg", + "zoom": 1 } } } diff --git a/test/image/mocks/scattermap_dynamic_defaults.json b/test/image/mocks/scattermap_dynamic_defaults.json new file mode 100644 index 00000000000..3da66a0d8bb --- /dev/null +++ b/test/image/mocks/scattermap_dynamic_defaults.json @@ -0,0 +1,13 @@ +{ + "data": [ + { + "hovertext": ["San Marino", "Cairo", "Istanbul", "Trondheim"], + "lat": [43.9360958, 30.06263, 41.01384, 63.43049], + "lon": [12.4417702, 31.24967, 28.94966, 10.39506], + "marker": { "color": "#f00" }, + "mode": "markers", + "type": "scattermap" + } + ], + "layout": { "width": 900, "height": 600 } +} diff --git a/test/jasmine/tests/map_get_fit_bounds_test.js b/test/jasmine/tests/map_get_fit_bounds_test.js new file mode 100644 index 00000000000..655ae72fb6a --- /dev/null +++ b/test/jasmine/tests/map_get_fit_bounds_test.js @@ -0,0 +1,136 @@ +const { getMapFitBounds } = require('../../../src/plots/map/get_map_fit_bounds'); + +// Fabricate a fullData-shaped trace for testing without spinning up Plotly. +function scattermap(overrides) { + return Object.assign({ + type: 'scattermap', + subplot: 'map', + visible: true, + lon: [], + lat: [] + }, overrides); +} + +function densitymap(overrides) { + return Object.assign({}, scattermap(overrides), { type: 'densitymap' }); +} + +function choroplethmap(overrides) { + return Object.assign({ + type: 'choroplethmap', + subplot: 'map', + visible: true, + locations: ['USA'] + }, overrides); +} + +describe('Test getMapFitBounds', () => { + it('returns a lon/lat box for a single scattermap trace', () => { + const fullData = [scattermap({ + lon: [-10, 20, 5], + lat: [40, 50, 45] + })]; + expect(getMapFitBounds(fullData, 'map')).toEqual({ + west: -10, east: 20, south: 40, north: 50 + }); + }); + + it('combines lon/lat across multiple visible traces on the same subplot', () => { + const fullData = [ + scattermap({ lon: [-10, 20], lat: [40, 50] }), + scattermap({ lon: [5, 30], lat: [35, 45] }) + ]; + expect(getMapFitBounds(fullData, 'map')).toEqual({ + west: -10, east: 30, south: 35, north: 50 + }); + }); + + it('treats densitymap traces the same as scattermap for lon/lat contribution', () => { + const fullData = [ + scattermap({ lon: [10, 20], lat: [40, 50] }), + densitymap({ lon: [5, 25], lat: [30, 55] }) + ]; + expect(getMapFitBounds(fullData, 'map')).toEqual({ + west: 5, east: 25, south: 30, north: 55 + }); + }); + + it('uses the compact antimeridian-crossing range when data straddles ±180°', () => { + const fullData = [scattermap({ + lon: [170, 175, -175, -170], + lat: [-10, 0, 10, 20] + })]; + const bounds = getMapFitBounds(fullData, 'map'); + // getFitboundsLonRange picks the tight west→east arc across the antimeridian + expect(bounds.west).toBe(170); + expect(bounds.east).toBe(190); + expect(bounds.south).toBe(-10); + expect(bounds.north).toBe(20); + }); + + it('ignores traces on other subplots', () => { + const fullData = [ + scattermap({ subplot: 'map2', lon: [100, 110], lat: [10, 20] }), + scattermap({ lon: [-10, 10], lat: [30, 40] }) + ]; + expect(getMapFitBounds(fullData, 'map')).toEqual({ + west: -10, east: 10, south: 30, north: 40 + }); + }); + + it('computes bounds for the target subplot when data lives on multiple map subplots', () => { + const fullData = [ + scattermap({ subplot: 'map', lon: [-10, 10], lat: [30, 40] }), + scattermap({ subplot: 'map2', lon: [100, 120], lat: [50, 60] }) + ]; + expect(getMapFitBounds(fullData, 'map2')).toEqual({ + west: 100, east: 120, south: 50, north: 60 + }); + }); + + it('ignores non-visible traces', () => { + const fullData = [ + scattermap({ visible: false, lon: [100, 110], lat: [10, 20] }), + scattermap({ lon: [-10, 10], lat: [30, 40] }) + ]; + expect(getMapFitBounds(fullData, 'map')).toEqual({ + west: -10, east: 10, south: 30, north: 40 + }); + }); + + it("ignores traces with visible: 'legendonly'", () => { + const fullData = [ + scattermap({ visible: 'legendonly', lon: [100, 110], lat: [10, 20] }), + scattermap({ lon: [-10, 10], lat: [30, 40] }) + ]; + expect(getMapFitBounds(fullData, 'map')).toEqual({ + west: -10, east: 10, south: 30, north: 40 + }); + }); + + it('returns null when a choroplethmap trace is present on the subplot', () => { + const fullData = [ + scattermap({ lon: [-10, 10], lat: [30, 40] }), + choroplethmap() + ]; + // location-based traces need geojson bbox handling — bail entirely + expect(getMapFitBounds(fullData, 'map')).toBe(null); + }); + + it('returns null when no visible trace contributes lonlat data', () => { + expect(getMapFitBounds([], 'map')).toBe(null); + expect(getMapFitBounds([scattermap({ lon: [], lat: [] })], 'map')).toBe(null); + expect(getMapFitBounds([scattermap({ lon: [NaN], lat: [NaN] })], 'map')).toBe(null); + }); + + it('skips non-finite lonlat entries in an otherwise valid trace', () => { + const fullData = [scattermap({ + lon: [-10, NaN, 20, null], + lat: [40, 50, NaN, 45] + })]; + // Only the (-10, 40) pair is fully finite → single-point box + expect(getMapFitBounds(fullData, 'map')).toEqual({ + west: -10, east: -10, south: 40, north: 40 + }); + }); +}); diff --git a/test/jasmine/tests/plot_api_react_test.js b/test/jasmine/tests/plot_api_react_test.js index 8eb8ffbef7f..a47ac701cc3 100644 --- a/test/jasmine/tests/plot_api_react_test.js +++ b/test/jasmine/tests/plot_api_react_test.js @@ -1683,16 +1683,15 @@ describe('Plotly.react and uirevision attributes', function() { data: [{lat: [1, 2], lon: [1, 2], type: 'scattermap'}], layout: { uirevision: mainRev, - map: {uirevision: mapRev} + // Use explicit zoom to opt out of the v4 default auto-fit + map: {uirevision: mapRev, zoom: 1} } }; } function attrs(original) { return { - 'map.center.lat': original ? [undefined, 0] : 1, - 'map.center.lon': original ? [undefined, 0] : 2, - 'map.zoom': original ? [undefined, 1] : 3, + 'map.zoom': original ? [1, 1] : 3, 'map.bearing': original ? [undefined, 0] : 4, 'map.pitch': original ? [undefined, 0] : 5 }; @@ -2071,7 +2070,9 @@ describe('Test Plotly.react + interactions under uirevision:', function() { }], { width: 500, height: 500, - uirevision: true + uirevision: true, + // Opt out of the v4 default auto-fit + map: { zoom: 1 } }); } @@ -2112,12 +2113,12 @@ describe('Test Plotly.react + interactions under uirevision:', function() { var preGUI = gd._fullLayout._preGUI; expect(preGUI['map.center.lon']).toBe(null, msg); expect(preGUI['map.center.lat']).toBe(null, msg); - expect(preGUI['map.zoom']).toBe(null, msg); + expect(preGUI['map.zoom']).toBe(1, msg); } _react() .then(function() { - expect(gd.layout.map).toEqual({}); + expect(gd.layout.map).toEqual({ zoom: 1 }); var fullMap = gd._fullLayout.map; expect(fullMap.center.lon).toBe(0); diff --git a/test/jasmine/tests/scattermap_test.js b/test/jasmine/tests/scattermap_test.js index 893fbef561e..4aac2bf4435 100644 --- a/test/jasmine/tests/scattermap_test.js +++ b/test/jasmine/tests/scattermap_test.js @@ -721,7 +721,9 @@ describe('scattermap hover', function() { text: ['A', 'B', 'C', 'D'] }]; - Plotly.newPlot(gd, data, { autosize: true }).then(done); + // Set zoom to opt out of the v4 default auto-fit so the that + // hover-pixel assertions still match + Plotly.newPlot(gd, data, { autosize: true, map: { zoom: 1 } }).then(done); }); afterAll(function() {