From 692fd9632115f9e5640d0161c32a1e9765744d66 Mon Sep 17 00:00:00 2001 From: kirthi_b Date: Wed, 22 Jul 2026 11:02:29 -0700 Subject: [PATCH] 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": {