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..6df7f28f1f5 --- /dev/null +++ b/src/plots/map/get_map_fit_bounds.ts @@ -0,0 +1,55 @@ +import { computeBbox } 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 coordinates: [number, number][] = []; + + 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)) coordinates.push([lo, la]); + } + } + + const bbox = computeBbox({ type: 'MultiPoint', coordinates }); + if (!bbox) return null; + + const [west, south, east, north] = bbox; + return { west, east, south, north }; +} diff --git a/src/plots/map/index.js b/src/plots/map/index.js index bd074948584..fe626962924 100644 --- a/src/plots/map/index.js +++ b/src/plots/map/index.js @@ -55,15 +55,6 @@ exports.plot = function plot(gd) { fullLayout[id]._subplot = map; } - if(!map.viewInitial) { - map.viewInitial = { - center: Lib.extendFlat({}, opts.center), - zoom: opts.zoom, - bearing: opts.bearing, - pitch: opts.pitch - }; - } - map.plot(subplotCalcData, fullLayout, gd._promises); } }; diff --git a/src/plots/map/layout_attributes.js b/src/plots/map/layout_attributes.js index f05839e491a..8495b5dc13c 100644 --- a/src/plots/map/layout_attributes.js +++ b/src/plots/map/layout_attributes.js @@ -22,302 +22,309 @@ var fontAttr = fontAttrs({ }); fontAttr.family.dflt = 'Open Sans Regular, Arial Unicode MS Regular'; -var attrs = module.exports = overrideAll({ - _arrayAttrRegexps: [Lib.counterRegex('map', '.layers', true)], +var attrs = (module.exports = overrideAll( + { + _arrayAttrRegexps: [Lib.counterRegex('map', '.layers', true)], - domain: domainAttrs({name: 'map'}), + domain: domainAttrs({ name: 'map' }), - style: { - valType: 'any', - values: constants.styleValuesMap, - dflt: constants.styleValueDflt, - description: [ - 'Defines the map layers that are rendered by default below the trace layers defined in `data`,', - 'which are themselves by default rendered below the layers defined in `layout.map.layers`.', - '', - 'These layers can be defined either explicitly as a Map Style object which can contain multiple', - 'layer definitions that load data from any public or private Tile Map Service (TMS or XYZ) or Web Map Service (WMS)', - 'or implicitly by using one of the built-in style objects which use WMSes', - 'or by using a custom style URL', - '', - 'Map Style objects are of the form described in the MapLibre GL JS documentation available at', - 'https://maplibre.org/maplibre-style-spec/', - '', - 'The built-in plotly.js styles objects are:', constants.styleValuesMap.join(', ') + '.' - ].join(' ') - }, - - center: { - lon: { - valType: 'number', - dflt: 0, - description: 'Sets the longitude of the center of the map (in degrees East).' - }, - lat: { - valType: 'number', - dflt: 0, - description: 'Sets the latitude of the center of the map (in degrees North).' - } - }, - zoom: { - valType: 'number', - dflt: 1, - description: 'Sets the zoom level of the map (map.zoom).' - }, - bearing: { - valType: 'number', - dflt: 0, - description: 'Sets the bearing angle of the map in degrees counter-clockwise from North (map.bearing).' - }, - pitch: { - valType: 'number', - dflt: 0, - description: [ - 'Sets the pitch angle of the map', - '(in degrees, where *0* means perpendicular to the surface of the map) (map.pitch).' - ].join(' ') - }, - - bounds: { - west: { - valType: 'number', - description: [ - 'Sets the minimum longitude of the map (in degrees East)', - 'if `east`, `south` and `north` are declared.' - ].join(' ') - }, - east: { - valType: 'number', - description: [ - 'Sets the maximum longitude of the map (in degrees East)', - 'if `west`, `south` and `north` are declared.' - ].join(' ') - }, - south: { - valType: 'number', - description: [ - 'Sets the minimum latitude of the map (in degrees North)', - 'if `east`, `west` and `north` are declared.' - ].join(' ') - }, - north: { - valType: 'number', - description: [ - 'Sets the maximum latitude of the map (in degrees North)', - 'if `east`, `west` and `south` are declared.' - ].join(' ') - } - }, - - layers: templatedArray('layer', { - visible: { - valType: 'boolean', - dflt: true, - description: [ - 'Determines whether this layer is displayed' - ].join(' ') - }, - sourcetype: { - valType: 'enumerated', - values: ['geojson', 'vector', 'raster', 'image'], - dflt: 'geojson', - description: [ - 'Sets the source type for this layer,', - 'that is the type of the layer data.' - ].join(' ') - }, - - source: { - valType: 'any', - description: [ - 'Sets the source data for this layer (map.layer.source).', - 'When `sourcetype` is set to *geojson*, `source` can be a URL to a GeoJSON', - 'or a GeoJSON object.', - 'When `sourcetype` is set to *vector* or *raster*, `source` can be a URL or', - 'an array of tile URLs.', - 'When `sourcetype` is set to *image*, `source` can be a URL to an image.' - ].join(' ') - }, - - sourcelayer: { - valType: 'string', - dflt: '', - description: [ - 'Specifies the layer to use from a vector tile source (map.layer.source-layer).', - 'Required for *vector* source type that supports multiple layers.' - ].join(' ') - }, - - sourceattribution: { - valType: 'string', - description: [ - 'Sets the attribution for this source.' - ].join(' ') - }, - - type: { - valType: 'enumerated', - values: ['circle', 'line', 'fill', 'symbol', 'raster'], - dflt: 'circle', - description: [ - 'Sets the layer type,', - 'that is the how the layer data set in `source` will be rendered', - 'With `sourcetype` set to *geojson*, the following values are allowed:', - '*circle*, *line*, *fill* and *symbol*.', - 'but note that *line* and *fill* are not compatible with Point', - 'GeoJSON geometries.', - 'With `sourcetype` set to *vector*, the following values are allowed:', - ' *circle*, *line*, *fill* and *symbol*.', - 'With `sourcetype` set to *raster* or *image*, only the *raster* value is allowed.' - ].join(' ') - }, - - coordinates: { + style: { valType: 'any', + values: constants.styleValuesMap, + dflt: constants.styleValueDflt, description: [ - 'Sets the coordinates array contains [longitude, latitude] pairs', - 'for the image corners listed in clockwise order:', - 'top left, top right, bottom right, bottom left.', - 'Only has an effect for *image* `sourcetype`.' + 'Defines the map layers that are rendered by default below the trace layers defined in `data`,', + 'which are themselves by default rendered below the layers defined in `layout.map.layers`.', + '', + 'These layers can be defined either explicitly as a Map Style object which can contain multiple', + 'layer definitions that load data from any public or private Tile Map Service (TMS or XYZ) or Web Map Service (WMS)', + 'or implicitly by using one of the built-in style objects which use WMSes', + 'or by using a custom style URL', + '', + 'Map Style objects are of the form described in the MapLibre GL JS documentation available at', + 'https://maplibre.org/maplibre-style-spec/', + '', + 'The built-in plotly.js styles objects are:', + constants.styleValuesMap.join(', ') + '.' ].join(' ') }, - // attributes shared between all types - below: { - valType: 'string', - description: [ - 'Determines if the layer will be inserted', - 'before the layer with the specified ID.', - 'If omitted or set to \'\',', - 'the layer will be inserted above every existing layer.' - ].join(' ') - }, - color: { - valType: 'color', - dflt: defaultLine, - description: [ - 'Sets the primary layer color.', - 'If `type` is *circle*, color corresponds to the circle color (map.layer.paint.circle-color)', - 'If `type` is *line*, color corresponds to the line color (map.layer.paint.line-color)', - 'If `type` is *fill*, color corresponds to the fill color (map.layer.paint.fill-color)', - 'If `type` is *symbol*, color corresponds to the icon color (map.layer.paint.icon-color)' - ].join(' ') + center: { + lon: { + valType: 'number', + dflt: 0, + description: 'Sets the longitude of the center of the map (in degrees East).' + }, + lat: { + valType: 'number', + dflt: 0, + description: 'Sets the latitude of the center of the map (in degrees North).' + } }, - opacity: { + zoom: { valType: 'number', - min: 0, - max: 1, dflt: 1, - description: [ - 'Sets the opacity of the layer.', - 'If `type` is *circle*, opacity corresponds to the circle opacity (map.layer.paint.circle-opacity)', - 'If `type` is *line*, opacity corresponds to the line opacity (map.layer.paint.line-opacity)', - 'If `type` is *fill*, opacity corresponds to the fill opacity (map.layer.paint.fill-opacity)', - 'If `type` is *symbol*, opacity corresponds to the icon/text opacity (map.layer.paint.text-opacity)' - ].join(' ') + description: 'Sets the zoom level of the map (map.zoom).' + }, + bearing: { + valType: 'number', + dflt: 0, + description: 'Sets the bearing angle of the map in degrees counter-clockwise from North (map.bearing).' }, - minzoom: { + pitch: { valType: 'number', - min: 0, - max: 24, dflt: 0, description: [ - 'Sets the minimum zoom level (map.layer.minzoom).', - 'At zoom levels less than the minzoom, the layer will be hidden.', + 'Sets the pitch angle of the map', + '(in degrees, where *0* means perpendicular to the surface of the map) (map.pitch).' ].join(' ') }, - maxzoom: { - valType: 'number', - min: 0, - max: 24, - dflt: 24, + + fitbounds: { + valType: 'boolean', + dflt: true, description: [ - 'Sets the maximum zoom level (map.layer.maxzoom).', - 'At zoom levels equal to or greater than the maxzoom, the layer will be hidden.' + 'Determines whether the map view auto-fits to the data.', + 'For *true* (default), `center` and `zoom` are computed from the provided lon/lat coordinates,', + 'For *false*, `center` and `zoom` are computed from the schema defaults or user-supplied values.', + 'If `fitbounds` is enabled but a user provides `center` or `zoom`, auto-fit will be skipped.' ].join(' ') }, - // type-specific style attributes - circle: { - radius: { + bounds: { + west: { valType: 'number', - dflt: 15, description: [ - 'Sets the circle radius (map.layer.paint.circle-radius).', - 'Has an effect only when `type` is set to *circle*.' + 'Sets the minimum longitude of the map (in degrees East)', + 'if `east`, `south` and `north` are declared.' ].join(' ') - } - }, - - line: { - width: { + }, + east: { + valType: 'number', + description: [ + 'Sets the maximum longitude of the map (in degrees East)', + 'if `west`, `south` and `north` are declared.' + ].join(' ') + }, + south: { valType: 'number', - dflt: 2, description: [ - 'Sets the line width (map.layer.paint.line-width).', - 'Has an effect only when `type` is set to *line*.' + 'Sets the minimum latitude of the map (in degrees North)', + 'if `east`, `west` and `north` are declared.' ].join(' ') }, - dash: { - valType: 'data_array', + north: { + valType: 'number', description: [ - 'Sets the length of dashes and gaps (map.layer.paint.line-dasharray).', - 'Has an effect only when `type` is set to *line*.' + 'Sets the maximum latitude of the map (in degrees North)', + 'if `east`, `west` and `south` are declared.' ].join(' ') } }, - fill: { - outlinecolor: { - valType: 'color', - dflt: defaultLine, + layers: templatedArray('layer', { + visible: { + valType: 'boolean', + dflt: true, + description: ['Determines whether this layer is displayed'].join(' ') + }, + sourcetype: { + valType: 'enumerated', + values: ['geojson', 'vector', 'raster', 'image'], + dflt: 'geojson', + description: ['Sets the source type for this layer,', 'that is the type of the layer data.'].join(' ') + }, + + source: { + valType: 'any', description: [ - 'Sets the fill outline color (map.layer.paint.fill-outline-color).', - 'Has an effect only when `type` is set to *fill*.' + 'Sets the source data for this layer (map.layer.source).', + 'When `sourcetype` is set to *geojson*, `source` can be a URL to a GeoJSON', + 'or a GeoJSON object.', + 'When `sourcetype` is set to *vector* or *raster*, `source` can be a URL or', + 'an array of tile URLs.', + 'When `sourcetype` is set to *image*, `source` can be a URL to an image.' ].join(' ') - } - }, + }, - symbol: { - icon: { + sourcelayer: { valType: 'string', - dflt: 'marker', + dflt: '', description: [ - 'Sets the symbol icon image (map.layer.layout.icon-image).', - 'Full list: https://www.mapbox.com/maki-icons/' + 'Specifies the layer to use from a vector tile source (map.layer.source-layer).', + 'Required for *vector* source type that supports multiple layers.' ].join(' ') }, - iconsize: { - valType: 'number', - dflt: 10, + + sourceattribution: { + valType: 'string', + description: ['Sets the attribution for this source.'].join(' ') + }, + + type: { + valType: 'enumerated', + values: ['circle', 'line', 'fill', 'symbol', 'raster'], + dflt: 'circle', + description: [ + 'Sets the layer type,', + 'that is the how the layer data set in `source` will be rendered', + 'With `sourcetype` set to *geojson*, the following values are allowed:', + '*circle*, *line*, *fill* and *symbol*.', + 'but note that *line* and *fill* are not compatible with Point', + 'GeoJSON geometries.', + 'With `sourcetype` set to *vector*, the following values are allowed:', + ' *circle*, *line*, *fill* and *symbol*.', + 'With `sourcetype` set to *raster* or *image*, only the *raster* value is allowed.' + ].join(' ') + }, + + coordinates: { + valType: 'any', description: [ - 'Sets the symbol icon size (map.layer.layout.icon-size).', - 'Has an effect only when `type` is set to *symbol*.' + 'Sets the coordinates array contains [longitude, latitude] pairs', + 'for the image corners listed in clockwise order:', + 'top left, top right, bottom right, bottom left.', + 'Only has an effect for *image* `sourcetype`.' ].join(' ') }, - text: { + + // attributes shared between all types + below: { valType: 'string', - dflt: '', description: [ - 'Sets the symbol text (map.layer.layout.text-field).' + 'Determines if the layer will be inserted', + 'before the layer with the specified ID.', + "If omitted or set to '',", + 'the layer will be inserted above every existing layer.' ].join(' ') }, - placement: { - valType: 'enumerated', - values: ['point', 'line', 'line-center'], - dflt: 'point', + color: { + valType: 'color', + dflt: defaultLine, + description: [ + 'Sets the primary layer color.', + 'If `type` is *circle*, color corresponds to the circle color (map.layer.paint.circle-color)', + 'If `type` is *line*, color corresponds to the line color (map.layer.paint.line-color)', + 'If `type` is *fill*, color corresponds to the fill color (map.layer.paint.fill-color)', + 'If `type` is *symbol*, color corresponds to the icon color (map.layer.paint.icon-color)' + ].join(' ') + }, + opacity: { + valType: 'number', + min: 0, + max: 1, + dflt: 1, + description: [ + 'Sets the opacity of the layer.', + 'If `type` is *circle*, opacity corresponds to the circle opacity (map.layer.paint.circle-opacity)', + 'If `type` is *line*, opacity corresponds to the line opacity (map.layer.paint.line-opacity)', + 'If `type` is *fill*, opacity corresponds to the fill opacity (map.layer.paint.fill-opacity)', + 'If `type` is *symbol*, opacity corresponds to the icon/text opacity (map.layer.paint.text-opacity)' + ].join(' ') + }, + minzoom: { + valType: 'number', + min: 0, + max: 24, + dflt: 0, + description: [ + 'Sets the minimum zoom level (map.layer.minzoom).', + 'At zoom levels less than the minzoom, the layer will be hidden.' + ].join(' ') + }, + maxzoom: { + valType: 'number', + min: 0, + max: 24, + dflt: 24, description: [ - 'Sets the symbol and/or text placement (map.layer.layout.symbol-placement).', - 'If `placement` is *point*, the label is placed where the geometry is located', - 'If `placement` is *line*, the label is placed along the line of the geometry', - 'If `placement` is *line-center*, the label is placed on the center of the geometry', + 'Sets the maximum zoom level (map.layer.maxzoom).', + 'At zoom levels equal to or greater than the maxzoom, the layer will be hidden.' ].join(' ') }, - textfont: fontAttr, - textposition: Lib.extendFlat({}, textposition, { arrayOk: false }) - } - }) -}, 'plot', 'from-root'); + + // type-specific style attributes + circle: { + radius: { + valType: 'number', + dflt: 15, + description: [ + 'Sets the circle radius (map.layer.paint.circle-radius).', + 'Has an effect only when `type` is set to *circle*.' + ].join(' ') + } + }, + + line: { + width: { + valType: 'number', + dflt: 2, + description: [ + 'Sets the line width (map.layer.paint.line-width).', + 'Has an effect only when `type` is set to *line*.' + ].join(' ') + }, + dash: { + valType: 'data_array', + description: [ + 'Sets the length of dashes and gaps (map.layer.paint.line-dasharray).', + 'Has an effect only when `type` is set to *line*.' + ].join(' ') + } + }, + + fill: { + outlinecolor: { + valType: 'color', + dflt: defaultLine, + description: [ + 'Sets the fill outline color (map.layer.paint.fill-outline-color).', + 'Has an effect only when `type` is set to *fill*.' + ].join(' ') + } + }, + + symbol: { + icon: { + valType: 'string', + dflt: 'marker', + description: [ + 'Sets the symbol icon image (map.layer.layout.icon-image).', + 'Full list: https://www.mapbox.com/maki-icons/' + ].join(' ') + }, + iconsize: { + valType: 'number', + dflt: 10, + description: [ + 'Sets the symbol icon size (map.layer.layout.icon-size).', + 'Has an effect only when `type` is set to *symbol*.' + ].join(' ') + }, + text: { + valType: 'string', + dflt: '', + description: ['Sets the symbol text (map.layer.layout.text-field).'].join(' ') + }, + placement: { + valType: 'enumerated', + values: ['point', 'line', 'line-center'], + dflt: 'point', + description: [ + 'Sets the symbol and/or text placement (map.layer.layout.symbol-placement).', + 'If `placement` is *point*, the label is placed where the geometry is located', + 'If `placement` is *line*, the label is placed along the line of the geometry', + 'If `placement` is *line-center*, the label is placed on the center of the geometry' + ].join(' ') + }, + textfont: fontAttr, + textposition: Lib.extendFlat({}, textposition, { arrayOk: false }) + } + }) + }, + 'plot', + 'from-root' +)); // set uirevision outside of overrideAll so it can be `editType: 'none'` attrs.uirevision = { diff --git a/src/plots/map/layout_defaults.js b/src/plots/map/layout_defaults.js index 5cb2531fa1c..d266824328e 100644 --- a/src/plots/map/layout_defaults.js +++ b/src/plots/map/layout_defaults.js @@ -5,35 +5,32 @@ 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'); coerce('zoom'); coerce('bearing'); coerce('pitch'); + const fitbounds = coerce('fitbounds'); var west = coerce('bounds.west'); 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 +39,32 @@ function handleDefaults(containerIn, containerOut, coerce) { handleItemDefaults: handleLayerDefaults }); + // Explicitly assign `_fitBounds` (even when null) so `relinkPrivateKeys` + // doesn't carry a stale value forward from the previous render + containerOut._fitBounds = null; + + // Fit the view to the data. Runs when no view attributes have + // been set (initial render, or user cleared them) OR when they still + // hold the exact values that were auto-computed last time. Skipped when + // the user has explicitly opted out via `fitbounds: false`. + const { + _fitView: { center: fitCenter, zoom: fitZoom, bearing: fitBearing, pitch: fitPitch } = {}, + center, + zoom, + bearing, + pitch + } = containerIn; + const isFitView = + bearing === fitBearing && + center?.lon === fitCenter?.lon && + center?.lat === fitCenter?.lat && + pitch === fitPitch && + zoom === fitZoom; + if (fitbounds && isFitView) { + 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 +75,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 +106,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 +128,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..6d966a47714 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,120 @@ 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) saveViewToLayout(self, opts); + // Save viewInitial here to get values from the post-load map view + updateViewInitial(self, opts); + 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 +193,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 +201,100 @@ 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); + const fitBounds = opts._fitBounds; + if (fitBounds) { + // Apply new bounds via map.fitBounds since map already exists + map.fitBounds( + [ + [fitBounds.west, fitBounds.south], + [fitBounds.east, fitBounds.north] + ], + // Don't animate to get proper transform values immediately + // (we need the values after the view transition; not before or mid) + { padding: constants.fitBoundsPadding, animate: false } + ); + saveViewToLayout(self, opts); + updateViewInitial(self, opts); + } + self.updateLayout(fullLayout); + self.resolveOnRender(resolve); + }) + .catch(reject); }; -proto.fillBelowLookup = function(calcData, fullLayout) { +// Persist the current map view state +function saveViewToLayout(self, opts) { + const { center, zoom } = self.getView(); + opts._input.center = opts.center = center; + opts._input.zoom = opts.zoom = zoom; + // Use below to determine if the view is still on the fit view during supplyDefaults. + // Bearing and pitch aren't set by auto-fit, but a user change to either signals + // they've engaged with the view and don't want it refit under them. + opts._input._fitView = { + center: { ...center }, + zoom, + bearing: opts._input.bearing, + pitch: opts._input.pitch + }; +} + +// Snapshot the current map view state for the Reset view modebar +// button and dblclick handler +function updateViewInitial(self, opts) { + const view = self.getView(); + self.viewInitial = { + center: Lib.extendFlat({}, view.center), + bearing: opts.bearing, + pitch: opts.pitch, + zoom: view.zoom + }; +} + +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 +314,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 +350,7 @@ var traceType2orderIndex = { scattermap: 2 }; -proto.updateData = function(calcData) { +proto.updateData = function (calcData) { var traceHash = this.traceHash; var traceObj, trace, i, j; @@ -282,43 +358,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 +399,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 +415,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 +437,7 @@ proto.resolveOnRender = function(resolve) { }); }; -proto.rejectOnError = function(reject) { +proto.rejectOnError = function (reject) { var map = this.map; function handler() { @@ -379,10 +451,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 +462,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 +484,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 +503,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 +514,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 +558,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 +576,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 +598,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 +607,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 +627,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 +642,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 +677,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 +702,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 +719,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 +729,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 +836,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 +849,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 +859,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 +875,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/src/types/generated/schema.d.ts b/src/types/generated/schema.d.ts index 92fdc3e79d7..a26f1daddb1 100644 --- a/src/types/generated/schema.d.ts +++ b/src/types/generated/schema.d.ts @@ -12626,6 +12626,11 @@ export interface MapLayout { lon?: number; }; domain?: Domain; + /** + * Determines whether the map view auto-fits to the data. For *true* (default), `center` and `zoom` are computed from the provided lon/lat coordinates, For *false*, `center` and `zoom` are computed from the schema defaults or user-supplied values. If `fitbounds` is enabled but a user provides `center` or `zoom`, auto-fit will be skipped. + * @default true + */ + fitbounds?: boolean; layers?: Array<{ /** Determines if the layer will be inserted before the layer with the specified ID. If omitted or set to '', the layer will be inserted above every existing layer. */ below?: string; 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..544146ad57a --- /dev/null +++ b/test/jasmine/tests/map_get_fit_bounds_test.js @@ -0,0 +1,179 @@ +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'); + expect(bounds.west).toBe(170); + expect(bounds.east).toBe(190); + expect(bounds.south).toBe(-10); + expect(bounds.north).toBe(20); + }); + + it('keeps the naive range when the interior gap is narrower than the antimeridian gap', () => { + // Span = 200°, antimeridian gap = 160°. Widest interior gap (100°) fits + // inside the antimeridian gap, so the naive [-100, 100] arc wins. + const fullData = [ + scattermap({ + lon: [-100, 0, 100], + lat: [0, 10, 20] + }) + ]; + expect(getMapFitBounds(fullData, 'map')).toEqual({ + west: -100, + east: 100, + south: 0, + north: 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/map_test.js b/test/jasmine/tests/map_test.js index 14be643b757..47238ebb1ab 100644 --- a/test/jasmine/tests/map_test.js +++ b/test/jasmine/tests/map_test.js @@ -1430,6 +1430,103 @@ describe('map plots', function() { } }); +describe('map auto-fit', () => { + let gd; + + beforeEach(() => { + gd = createGraphDiv(); + }); + afterEach(() => { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + const mock = (overrides = {}) => { + return Lib.extendDeep( + { + data: [{ type: 'scattermap', mode: 'markers', lon: [-75, -74, -73], lat: [40, 41, 42] }], + layout: { width: 400, height: 400 } + }, + overrides + ); + }; + + it( + '@gl frames the data when no center/zoom set', + async () => { + await Plotly.newPlot(gd, mock()); + const { lng, lat } = gd._fullLayout.map._subplot.map.getCenter(); + expect(lng).toBeCloseTo(-74, 0); + expect(lat).toBeCloseTo(41, 0); + }, + LONG_TIMEOUT_INTERVAL + ); + + it( + '@gl refits when lon/lat change via restyle', + async () => { + await Plotly.newPlot(gd, mock()); + const z0 = gd._fullLayout.map.zoom; + await Plotly.restyle(gd, { lon: [[-75]], lat: [[40]] }, [0]); + // Single-point trace → tighter zoom than the 3-point cluster + expect(gd._fullLayout.map.zoom).toBeGreaterThan(z0); + }, + LONG_TIMEOUT_INTERVAL + ); + + it( + '@gl preserves center when only zoom is relayout-ed', + async () => { + await Plotly.newPlot(gd, mock()); + const { lon: origLon, lat: origLat } = gd._fullLayout.map.center; + await Plotly.relayout(gd, { 'map.zoom': 5 }); + expect(gd._fullLayout.map.zoom).toBe(5); + expect(gd._fullLayout.map.center.lon).toBe(origLon); + expect(gd._fullLayout.map.center.lat).toBe(origLat); + }, + LONG_TIMEOUT_INTERVAL + ); + + it( + '@gl skips auto-fit when fitbounds is false', + async () => { + await Plotly.newPlot(gd, mock({ layout: { map: { fitbounds: false } } })); + expect(gd._fullLayout.map.center.lon).toBe(0); + expect(gd._fullLayout.map.center.lat).toBe(0); + expect(gd._fullLayout.map.zoom).toBe(1); + }, + LONG_TIMEOUT_INTERVAL + ); + + it( + '@gl skips refit after the user has changed center', + async () => { + await Plotly.newPlot(gd, mock()); + await Plotly.relayout(gd, { 'map.center': { lon: 0, lat: 0 } }); + await Plotly.restyle(gd, { lon: [[-75]], lat: [[40]] }, [0]); + expect(gd._fullLayout.map.center.lon).toBe(0); + expect(gd._fullLayout.map.center.lat).toBe(0); + }, + LONG_TIMEOUT_INTERVAL + ); + + it( + '@gl refits after addTraces / deleteTraces', + async () => { + await Plotly.newPlot(gd, mock()); + const z0 = gd._fullLayout.map.zoom; + await Plotly.addTraces(gd, { type: 'scattermap', mode: 'markers', lon: [140], lat: [-30] }); + // New trace pulls the fit far wider → lower zoom + expect(gd._fullLayout.map.zoom).toBeLessThan(z0); + await Plotly.deleteTraces(gd, 1); + // Back to the original 3-point fit + expect(gd._fullLayout.map.zoom).toBeCloseTo(z0, 5); + }, + LONG_TIMEOUT_INTERVAL + ); + +}); + describe('map react', function() { var gd; 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() { diff --git a/test/plot-schema.json b/test/plot-schema.json index f4286815758..6b0303d8b84 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -3806,6 +3806,12 @@ } }, "editType": "plot", + "fitbounds": { + "description": "Determines whether the map view auto-fits to the data. For *true* (default), `center` and `zoom` are computed from the provided lon/lat coordinates, For *false*, `center` and `zoom` are computed from the schema defaults or user-supplied values. If `fitbounds` is enabled but a user provides `center` or `zoom`, auto-fit will be skipped.", + "dflt": true, + "editType": "plot", + "valType": "boolean" + }, "layers": { "items": { "layer": {