|
1 | 1 | 'use strict'; |
2 | 2 |
|
3 | 3 | var maplibregl = require('maplibre-gl'); |
| 4 | +var isNumeric = require('fast-isnumeric'); |
4 | 5 |
|
5 | 6 | var Lib = require('../../lib'); |
6 | 7 | var geoUtils = require('../../lib/geo_location_utils'); |
@@ -149,6 +150,20 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) { |
149 | 150 | Promise.all(promises).then(function() { |
150 | 151 | self.fillBelowLookup(calcData, fullLayout); |
151 | 152 | self.updateData(calcData); |
| 153 | + self.updateFitbounds(calcData, fullLayout); |
| 154 | + |
| 155 | + // capture the view *after* fitbounds has had a chance to compute |
| 156 | + // center/zoom, so that double-clicking to reset the view lands back |
| 157 | + // on the auto-fit framing rather than the pre-fit default. |
| 158 | + if(!self.viewInitial) { |
| 159 | + self.viewInitial = { |
| 160 | + center: Lib.extendFlat({}, opts.center), |
| 161 | + zoom: opts.zoom, |
| 162 | + bearing: opts.bearing, |
| 163 | + pitch: opts.pitch |
| 164 | + }; |
| 165 | + } |
| 166 | + |
152 | 167 | self.updateLayout(fullLayout); |
153 | 168 | self.resolveOnRender(resolve); |
154 | 169 | }).catch(reject); |
@@ -182,6 +197,7 @@ proto.updateMap = function(calcData, fullLayout, resolve, reject) { |
182 | 197 | Promise.all(promises).then(function() { |
183 | 198 | self.fillBelowLookup(calcData, fullLayout); |
184 | 199 | self.updateData(calcData); |
| 200 | + self.updateFitbounds(calcData, fullLayout); |
185 | 201 | self.updateLayout(fullLayout); |
186 | 202 | self.resolveOnRender(resolve); |
187 | 203 | }).catch(reject); |
@@ -327,6 +343,34 @@ proto.updateData = function(calcData) { |
327 | 343 | } |
328 | 344 | }; |
329 | 345 |
|
| 346 | +// Auto-compute `center` / `zoom` from the visible trace data on this |
| 347 | +// subplot, mirroring `geo.fitbounds`. Only takes effect when the user has |
| 348 | +// not explicitly set `center` or `zoom` (see `_fitboundsAuto`, computed in |
| 349 | +// layout_defaults.js) and leaves the manual pan/zoom values in place once |
| 350 | +// the user has interacted with the map - see the `moveend` handler in |
| 351 | +// `initFx` below, which clears `fitbounds` on GUI edits. |
| 352 | +proto.updateFitbounds = function(calcData, fullLayout) { |
| 353 | + var opts = fullLayout[this.id]; |
| 354 | + if(!opts._fitboundsAuto) return; |
| 355 | + |
| 356 | + var box = computeFitboundsBox(calcData, opts.fitbounds); |
| 357 | + if(!box) return; |
| 358 | + |
| 359 | + var bounds = [[box.lonMin, box.latMin], [box.lonMax, box.latMax]]; |
| 360 | + |
| 361 | + var camera; |
| 362 | + try { |
| 363 | + camera = this.map.cameraForBounds(bounds, {padding: constants.fitboundsPadding}); |
| 364 | + } catch(e) { |
| 365 | + Lib.warn('Something went wrong during ' + this.id + ' fitbounds computations.'); |
| 366 | + return; |
| 367 | + } |
| 368 | + if(!camera) return; |
| 369 | + |
| 370 | + opts.center = {lon: camera.center.lng, lat: camera.center.lat}; |
| 371 | + opts.zoom = camera.zoom; |
| 372 | +}; |
| 373 | + |
330 | 374 | proto.updateLayout = function(fullLayout) { |
331 | 375 | var map = this.map; |
332 | 376 | var opts = fullLayout[this.id]; |
@@ -429,14 +473,28 @@ proto.initFx = function(calcData, fullLayout) { |
429 | 473 |
|
430 | 474 | if(evt.originalEvent || self.wheeling) { |
431 | 475 | var optsNow = fullLayoutNow[self.id]; |
432 | | - Registry.call('_storeDirectGUIEdit', gd.layout, fullLayoutNow._preGUI, self.getViewEdits(optsNow)); |
| 476 | + |
| 477 | + // once the user has manually panned/zoomed, `fitbounds` must |
| 478 | + // stop recomputing `center` / `zoom` on subsequent draws - |
| 479 | + // mirrors `geo.fitbounds` getting cleared on GUI zoom/pan. |
| 480 | + var hadFitbounds = Boolean(optsNow.fitbounds); |
| 481 | + var viewEdits = self.getViewEdits(optsNow); |
| 482 | + if(hadFitbounds) viewEdits[self.id + '.fitbounds'] = false; |
| 483 | + Registry.call('_storeDirectGUIEdit', gd.layout, fullLayoutNow._preGUI, viewEdits); |
433 | 484 |
|
434 | 485 | var viewNow = self.getView(); |
435 | 486 | optsNow._input.center = optsNow.center = viewNow.center; |
436 | 487 | optsNow._input.zoom = optsNow.zoom = viewNow.zoom; |
437 | 488 | optsNow._input.bearing = optsNow.bearing = viewNow.bearing; |
438 | 489 | optsNow._input.pitch = optsNow.pitch = viewNow.pitch; |
439 | | - gd.emit('plotly_relayout', self.getViewEditsWithDerived(viewNow)); |
| 490 | + |
| 491 | + var eventEdits = self.getViewEditsWithDerived(viewNow); |
| 492 | + if(hadFitbounds) { |
| 493 | + optsNow._input.fitbounds = optsNow.fitbounds = false; |
| 494 | + optsNow._fitboundsAuto = false; |
| 495 | + eventEdits[self.id + '.fitbounds'] = false; |
| 496 | + } |
| 497 | + gd.emit('plotly_relayout', eventEdits); |
440 | 498 | } |
441 | 499 | if(evt.originalEvent && evt.originalEvent.type === 'mouseup') { |
442 | 500 | self.dragging = false; |
@@ -811,4 +869,72 @@ function convertCenter(center) { |
811 | 869 | return [center.lon, center.lat]; |
812 | 870 | } |
813 | 871 |
|
| 872 | +function extendLonLatBounds(box, lon, lat) { |
| 873 | + if(!isNumeric(lon) || !isNumeric(lat)) return; |
| 874 | + if(lon < box.lonMin) box.lonMin = lon; |
| 875 | + if(lon > box.lonMax) box.lonMax = lon; |
| 876 | + if(lat < box.latMin) box.latMin = lat; |
| 877 | + if(lat > box.latMax) box.latMax = lat; |
| 878 | + box.any = true; |
| 879 | +} |
| 880 | + |
| 881 | +// Compute a lon/lat bounding box from the visible map-type traces |
| 882 | +// (scattermap, densitymap, choroplethmap) on this subplot, to feed into |
| 883 | +// MapLibre's `cameraForBounds`. Point-based traces (scattermap, |
| 884 | +// densitymap) contribute their `lon`/`lat` values directly; choroplethmap |
| 885 | +// traces contribute either the bounding box of their matched geometries |
| 886 | +// (`fitbounds: 'locations'`) or of the entire input `geojson` |
| 887 | +// (`fitbounds: 'geojson'`), analogous to `geo.fitbounds`. |
| 888 | +function computeFitboundsBox(calcData, fitbounds) { |
| 889 | + var box = {lonMin: Infinity, lonMax: -Infinity, latMin: Infinity, latMax: -Infinity, any: false}; |
| 890 | + |
| 891 | + for(var i = 0; i < calcData.length; i++) { |
| 892 | + var calcTrace = calcData[i]; |
| 893 | + var trace = calcTrace[0].trace; |
| 894 | + if(trace.visible !== true) continue; |
| 895 | + |
| 896 | + if(trace.type === 'choroplethmap') { |
| 897 | + if(fitbounds === 'geojson') { |
| 898 | + var geojsonIn = geoUtils.getTraceGeojson(trace); |
| 899 | + if(geojsonIn) { |
| 900 | + var bbox = geoUtils.computeBbox(geojsonIn); |
| 901 | + extendLonLatBounds(box, bbox[0], bbox[1]); |
| 902 | + extendLonLatBounds(box, bbox[2], bbox[3]); |
| 903 | + } |
| 904 | + } else { |
| 905 | + for(var j = 0; j < calcTrace.length; j++) { |
| 906 | + var fOut = calcTrace[j].fOut; |
| 907 | + if(fOut) { |
| 908 | + var fbbox = geoUtils.computeBbox(fOut); |
| 909 | + extendLonLatBounds(box, fbbox[0], fbbox[1]); |
| 910 | + extendLonLatBounds(box, fbbox[2], fbbox[3]); |
| 911 | + } |
| 912 | + } |
| 913 | + } |
| 914 | + } else { |
| 915 | + for(var k = 0; k < calcTrace.length; k++) { |
| 916 | + var lonlat = calcTrace[k].lonlat; |
| 917 | + if(lonlat) extendLonLatBounds(box, lonlat[0], lonlat[1]); |
| 918 | + } |
| 919 | + } |
| 920 | + } |
| 921 | + |
| 922 | + if(!box.any) return null; |
| 923 | + |
| 924 | + // a single point (or multiple coincident points) collapses the box to |
| 925 | + // zero width/height, which some `cameraForBounds` implementations turn |
| 926 | + // into a non-finite zoom - pad it out to a small but non-degenerate |
| 927 | + // region centered on the point. |
| 928 | + if(box.lonMin === box.lonMax) { |
| 929 | + box.lonMin -= 0.1; |
| 930 | + box.lonMax += 0.1; |
| 931 | + } |
| 932 | + if(box.latMin === box.latMax) { |
| 933 | + box.latMin -= 0.1; |
| 934 | + box.latMax += 0.1; |
| 935 | + } |
| 936 | + |
| 937 | + return box; |
| 938 | +} |
| 939 | + |
814 | 940 | module.exports = Map; |
0 commit comments