From 692fd9632115f9e5640d0161c32a1e9765744d66 Mon Sep 17 00:00:00 2001 From: kirthi_b Date: Wed, 22 Jul 2026 11:02:29 -0700 Subject: [PATCH 1/2] Add choroplethmap support to map fitbounds Extend layout.map.fitbounds to cover choroplethmap traces, which the v4.0 auto-fit currently skips. With 'locations' (the default) the view fits to the bounding box of the geometries matched by the trace's locations; a new 'geojson' value fits to the entire input geojson instead. Point traces (scattermap, densitymap) are unaffected. Bounds are read at supply-defaults time from the resolved trace geojson, so a URL geojson that has not been fetched yet is skipped quietly rather than driving the fit or logging an error. --- draftlogs/7911_add.md | 1 + src/plots/map/get_map_fit_bounds.ts | 113 +++++++++++++++++++++++++--- src/plots/map/layout_attributes.js | 7 +- src/plots/map/layout_defaults.js | 2 +- src/types/generated/schema.d.ts | 4 +- test/jasmine/tests/map_test.js | 71 +++++++++++++++++ test/plot-schema.json | 5 +- 7 files changed, 185 insertions(+), 18 deletions(-) create mode 100644 draftlogs/7911_add.md diff --git a/draftlogs/7911_add.md b/draftlogs/7911_add.md new file mode 100644 index 00000000000..b3f42d5c4be --- /dev/null +++ b/draftlogs/7911_add.md @@ -0,0 +1 @@ +- Add `choroplethmap` support to `layout.map.fitbounds`, including a new `geojson` value that fits the view to the entire input `geojson` rather than only the matched `locations` [[#7911](https://github.com/plotly/plotly.js/pull/7911)] diff --git a/src/plots/map/get_map_fit_bounds.ts b/src/plots/map/get_map_fit_bounds.ts index 6df7f28f1f5..657c46102c9 100644 --- a/src/plots/map/get_map_fit_bounds.ts +++ b/src/plots/map/get_map_fit_bounds.ts @@ -4,6 +4,11 @@ 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>; +// `false` | 'locations' | 'geojson' - mirrors `layout.map.fitbounds` +type FitBounds = MapLayout['fitbounds']; + +type GeoJson = Record; + // Minimal shape of the fullData entries this helper reads interface FitBoundsTrace extends Pick { // Tighten lat/lon to be more specific than default @@ -11,30 +16,116 @@ interface FitBoundsTrace extends Pick { lon?: ArrayLike; // Broaden type since this could run against multiple trace types type?: string; + // choroplethmap traces carry these instead of raw lon/lat + locations?: ArrayLike; + geojson?: string | GeoJson; + featureidkey?: string; +} + +// Resolve a choroplethmap trace's geojson to a plain object without logging. +// `geojson` is either an inline object or a URL string that resolves against the +// global `PlotlyGeoAssets` cache once fetched. At supply-defaults time a URL may +// not be fetched yet, so return null quietly rather than error-logging the way +// `geoUtils.getTraceGeojson` does - the fit just skips this trace for now. +function resolveGeojson(trace: FitBoundsTrace): GeoJson | null { + const g = trace.geojson; + if (g && typeof g === 'object') return g; + if (typeof g === 'string' && typeof window !== 'undefined') { + const assets = (window as { PlotlyGeoAssets?: Record }).PlotlyGeoAssets || {}; + const cached = assets[g]; + if (cached && typeof cached === 'object') return cached; + } + return null; +} + +// Read a feature's id at `featureidkey` (default 'id'), walking a dotted path +// e.g. 'properties.name' - matches the lookup done in `extractTraceFeature`. +function getFeatureId(feature: GeoJson, featureidkey?: string): string | number | undefined { + const parts = (featureidkey || 'id').split('.'); + let cur: unknown = feature; + for (let i = 0; i < parts.length; i++) { + if (cur === null || cur === undefined || typeof cur !== 'object') return undefined; + cur = (cur as Record)[parts[i]]; + } + return typeof cur === 'string' || typeof cur === 'number' ? cur : undefined; +} + +// Build a GeoJSON FeatureCollection of only the features a choroplethmap trace +// actually references via `locations`, so `fitbounds: 'locations'` frames the +// matched geometries rather than the whole input `geojson`. +function matchedFeatureCollection(geojsonIn: GeoJson, trace: FitBoundsTrace): GeoJson | null { + const locations = trace.locations; + if (!locations || !locations.length) return null; + + const wanted: Record = {}; + for (let i = 0; i < locations.length; i++) wanted[String(locations[i])] = true; + + const featuresIn: GeoJson[] = geojsonIn.type === 'FeatureCollection' ? + (geojsonIn.features as GeoJson[]) : + geojsonIn.type === 'Feature' ? [geojsonIn] : []; + + const featuresOut: GeoJson[] = []; + for (let j = 0; j < featuresIn.length; j++) { + const id = getFeatureId(featuresIn[j], trace.featureidkey); + if (id !== undefined && wanted[String(id)]) featuresOut.push(featuresIn[j]); + } + + if (!featuresOut.length) return null; + return { type: 'FeatureCollection', features: featuresOut }; +} + +// Add a choroplethmap trace's geometry bounds to the running coordinate list by +// pushing the box corners as two points. `fitbounds: 'geojson'` frames the whole +// input geojson; anything else frames just the matched `locations`. Skips +// silently when the geojson is unavailable (e.g. a URL not yet fetched) so the +// rest of the subplot's data still drives the fit. +function pushChoroplethBounds( + coordinates: [number, number][], + trace: FitBoundsTrace, + fitbounds: FitBounds +): void { + const geojsonIn = resolveGeojson(trace); + if (!geojsonIn) return; + + const target = fitbounds === 'geojson' ? geojsonIn : matchedFeatureCollection(geojsonIn, trace); + if (!target) return; + + const bbox = computeBbox(target); + if (!bbox) return; + + const [west, south, east, north] = bbox; + coordinates.push([west, south], [east, north]); } /** - * Compute a lon/lat bounding box from lonlat-bearing traces (`scattermap`, - * `densitymap`) on the given map subplot. + * Compute a lon/lat bounding box for the visible traces on a map subplot, to + * feed MapLibre's auto-fit. * - * 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. + * Point traces (`scattermap`, `densitymap`) contribute their `lon`/`lat` + * values. `choroplethmap` traces contribute the bounding box of either their + * matched `locations` (default) or the entire input `geojson` (when + * `fitbounds` is 'geojson'), analogous to `geo.fitbounds`. + * + * Returns null when no fittable data exists on the subplot. * * @param fullData - The full data array (post supply-defaults) * @param subplotId - e.g. `'map'`, `'map2'` + * @param fitbounds - the subplot's `fitbounds` value ('locations' | 'geojson') */ -export function getMapFitBounds(fullData: FitBoundsTrace[], subplotId: string): LonLatBox | null { +export function getMapFitBounds( + fullData: FitBoundsTrace[], + subplotId: string, + fitbounds: FitBounds +): 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; + if (trace.type === 'choroplethmap') { + pushChoroplethBounds(coordinates, trace, fitbounds); + continue; + } const { lat, lon } = trace; if (!lon || !lat) continue; diff --git a/src/plots/map/layout_attributes.js b/src/plots/map/layout_attributes.js index 0c55c82f2e9..14e788c49fa 100644 --- a/src/plots/map/layout_attributes.js +++ b/src/plots/map/layout_attributes.js @@ -82,11 +82,14 @@ var attrs = (module.exports = overrideAll( fitbounds: { valType: 'enumerated', - values: [false, 'locations'], + values: [false, 'locations', 'geojson'], dflt: 'locations', description: [ "Determines if this subplot's view settings are auto-computed to fit trace data.", - 'If *locations* (default), the view is auto-fit to the lon/lat coordinates of the visible trace data.', + 'If *locations* (default), the view is auto-fit to the lon/lat coordinates of the visible trace data;', + 'for `choroplethmap` traces this is the bounding box of the geometries matched by `locations`.', + 'If *geojson*, `choroplethmap` traces are fit to the bounding box of their entire input `geojson`', + 'instead of only the matched locations (point traces still fit to their lon/lat data).', 'If *false*, the view settings are used as-is; set this to opt out of auto-fitting.', 'If `fitbounds` is enabled but a user provides `center` or `zoom`, auto-fit will be skipped.' ].join(' ') diff --git a/src/plots/map/layout_defaults.js b/src/plots/map/layout_defaults.js index 00b396e15f0..913c43c6d6a 100644 --- a/src/plots/map/layout_defaults.js +++ b/src/plots/map/layout_defaults.js @@ -55,7 +55,7 @@ function handleDefaults(containerIn, containerOut, coerce, opts) { const { _fitView: { center: fitCenter, zoom: fitZoom } = {}, center, zoom } = containerIn; const isFitView = center?.lon === fitCenter?.lon && center?.lat === fitCenter?.lat && zoom === fitZoom; if (fitbounds && isFitView) { - const fitBounds = getMapFitBounds(opts.fullData, opts.id); + const fitBounds = getMapFitBounds(opts.fullData, opts.id, fitbounds); if (fitBounds) containerOut._fitBounds = fitBounds; } diff --git a/src/types/generated/schema.d.ts b/src/types/generated/schema.d.ts index 215d180479c..ff61b379e39 100644 --- a/src/types/generated/schema.d.ts +++ b/src/types/generated/schema.d.ts @@ -12846,10 +12846,10 @@ export interface MapLayout { }; domain?: Domain; /** - * Determines if this subplot's view settings are auto-computed to fit trace data. If *locations* (default), the view is auto-fit to the lon/lat coordinates of the visible trace data. If *false*, the view settings are used as-is; set this to opt out of auto-fitting. If `fitbounds` is enabled but a user provides `center` or `zoom`, auto-fit will be skipped. + * Determines if this subplot's view settings are auto-computed to fit trace data. If *locations* (default), the view is auto-fit to the lon/lat coordinates of the visible trace data; for `choroplethmap` traces this is the bounding box of the geometries matched by `locations`. If *geojson*, `choroplethmap` traces are fit to the bounding box of their entire input `geojson` instead of only the matched locations (point traces still fit to their lon/lat data). If *false*, the view settings are used as-is; set this to opt out of auto-fitting. If `fitbounds` is enabled but a user provides `center` or `zoom`, auto-fit will be skipped. * @default 'locations' */ - fitbounds?: false | 'locations'; + fitbounds?: false | 'locations' | 'geojson'; 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/jasmine/tests/map_test.js b/test/jasmine/tests/map_test.js index 47238ebb1ab..ac33cdd90c4 100644 --- a/test/jasmine/tests/map_test.js +++ b/test/jasmine/tests/map_test.js @@ -1525,6 +1525,77 @@ describe('map auto-fit', () => { LONG_TIMEOUT_INTERVAL ); + // Two disjoint polygons: A (lon -100..-90, lat 30..40) and B (lon 0..10, + // lat 0..10). The trace only references A via `locations`. + const choroGeojson = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'A', + geometry: { type: 'Polygon', coordinates: [[[-100, 30], [-90, 30], [-90, 40], [-100, 40], [-100, 30]]] } + }, + { + type: 'Feature', + id: 'B', + geometry: { type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]] } + } + ] + }; + + const choroMock = (overrides = {}) => { + return Lib.extendDeep( + { + data: [{ type: 'choroplethmap', geojson: choroGeojson, locations: ['A'], z: [1] }], + layout: { width: 400, height: 400 } + }, + overrides + ); + }; + + it( + '@gl frames a choroplethmap to the bounding box of its matched locations', + async () => { + await Plotly.newPlot(gd, choroMock()); + const { lng, lat } = gd._fullLayout.map._subplot.map.getCenter(); + // Only feature A is referenced → center near its middle (-95, 35), + // not pulled toward the unmatched feature B. + expect(lng).toBeCloseTo(-95, 0); + expect(lat).toBeCloseTo(35, 0); + }, + LONG_TIMEOUT_INTERVAL + ); + + it( + '@gl fitbounds *geojson* widens the fit to the entire input geojson', + async () => { + await Plotly.newPlot(gd, choroMock()); + const zLoc = gd._fullLayout.map.zoom; + const cLoc = gd._fullLayout.map._subplot.map.getCenter().lng; + + await Plotly.newPlot(gd, choroMock({ layout: { map: { fitbounds: 'geojson' } } })); + const zAll = gd._fullLayout.map.zoom; + const cAll = gd._fullLayout.map._subplot.map.getCenter().lng; + + // *geojson* includes the unmatched feature B (east of A) → the fit is + // wider (lower zoom) and its center shifts east relative to *locations*. + expect(zAll).toBeLessThan(zLoc); + expect(cAll).toBeGreaterThan(cLoc); + }, + LONG_TIMEOUT_INTERVAL + ); + + it( + '@gl skips auto-fit for a choroplethmap when fitbounds is false', + async () => { + await Plotly.newPlot(gd, choroMock({ 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 + ); + }); describe('map react', function() { diff --git a/test/plot-schema.json b/test/plot-schema.json index e43f9d2f2d9..1952b770343 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -3807,13 +3807,14 @@ }, "editType": "plot", "fitbounds": { - "description": "Determines if this subplot's view settings are auto-computed to fit trace data. If *locations* (default), the view is auto-fit to the lon/lat coordinates of the visible trace data. If *false*, the view settings are used as-is; set this to opt out of auto-fitting. If `fitbounds` is enabled but a user provides `center` or `zoom`, auto-fit will be skipped.", + "description": "Determines if this subplot's view settings are auto-computed to fit trace data. If *locations* (default), the view is auto-fit to the lon/lat coordinates of the visible trace data; for `choroplethmap` traces this is the bounding box of the geometries matched by `locations`. If *geojson*, `choroplethmap` traces are fit to the bounding box of their entire input `geojson` instead of only the matched locations (point traces still fit to their lon/lat data). If *false*, the view settings are used as-is; set this to opt out of auto-fitting. If `fitbounds` is enabled but a user provides `center` or `zoom`, auto-fit will be skipped.", "dflt": "locations", "editType": "plot", "valType": "enumerated", "values": [ false, - "locations" + "locations", + "geojson" ] }, "layers": { From 7e817dfae7b6f88dda807530591c35f0e8896b4f Mon Sep 17 00:00:00 2001 From: kirthi_b Date: Mon, 3 Aug 2026 15:21:06 -0700 Subject: [PATCH 2/2] Fix stale test for choropleth fitbounds fallback behavior The test asserted that any choroplethmap trace on the subplot forces getMapFitBounds to return null. That was true before choroplethmap support was added, but the added commit intentionally lets a choroplethmap trace with unresolved geojson be skipped while other traces still drive the fit, so the old assertion no longer matches the documented behavior. Updated the test to check that case, and added the real null case: a subplot where the only trace is a choroplethmap with no resolvable geojson. --- test/jasmine/tests/map_get_fit_bounds_test.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/jasmine/tests/map_get_fit_bounds_test.js b/test/jasmine/tests/map_get_fit_bounds_test.js index 544146ad57a..3fadfacb16e 100644 --- a/test/jasmine/tests/map_get_fit_bounds_test.js +++ b/test/jasmine/tests/map_get_fit_bounds_test.js @@ -149,9 +149,20 @@ describe('Test getMapFitBounds', () => { }); }); - it('returns null when a choroplethmap trace is present on the subplot', () => { + it('skips a choroplethmap trace whose geojson has not resolved yet, using the other traces for bounds', () => { const fullData = [scattermap({ lon: [-10, 10], lat: [30, 40] }), choroplethmap()]; - // location-based traces need geojson bbox handling — bail entirely + // choroplethmap() has no geojson set, so it can't contribute a bbox and is skipped; + // the scattermap trace still drives the fit + expect(getMapFitBounds(fullData, 'map')).toEqual({ + west: -10, + east: 10, + south: 30, + north: 40 + }); + }); + + it('returns null when the only visible trace is a choroplethmap with unresolved geojson', () => { + const fullData = [choroplethmap()]; expect(getMapFitBounds(fullData, 'map')).toBe(null); });