Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions draftlogs/7911_add.md
Original file line number Diff line number Diff line change
@@ -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)]
113 changes: 102 additions & 11 deletions src/plots/map/get_map_fit_bounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,128 @@ 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<NonNullable<MapLayout['bounds']>>;

// `false` | 'locations' | 'geojson' - mirrors `layout.map.fitbounds`
type FitBounds = MapLayout['fitbounds'];

type GeoJson = Record<string, unknown>;

// Minimal shape of the fullData entries this helper reads
interface FitBoundsTrace extends Pick<ScattermapData, 'subplot' | 'visible'> {
// Tighten lat/lon to be more specific than default
lat?: ArrayLike<number>;
lon?: ArrayLike<number>;
// Broaden type since this could run against multiple trace types
type?: string;
// choroplethmap traces carry these instead of raw lon/lat
locations?: ArrayLike<string | number>;
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<string, GeoJson> }).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<string, unknown>)[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<string, boolean> = {};
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;
Expand Down
7 changes: 5 additions & 2 deletions src/plots/map/layout_attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(' ')
Expand Down
2 changes: 1 addition & 1 deletion src/plots/map/layout_defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions src/types/generated/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
71 changes: 71 additions & 0 deletions test/jasmine/tests/map_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
5 changes: 3 additions & 2 deletions test/plot-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading