diff --git a/modules/react-maplibre/src/maplibre/maplibre.ts b/modules/react-maplibre/src/maplibre/maplibre.ts index 4ef1122ad..751a32d40 100644 --- a/modules/react-maplibre/src/maplibre/maplibre.ts +++ b/modules/react-maplibre/src/maplibre/maplibre.ts @@ -1,4 +1,9 @@ -import {transformToViewState, applyViewStateToTransform} from '../utils/transform'; +import { + transformToViewState, + applyViewStateToTransform, + updateZoomConstraint, + updatePitchConstraint +} from '../utils/transform'; import {normalizeStyle} from '../utils/style-utils'; import {deepEqual} from '../utils/deep-equal'; @@ -76,6 +81,31 @@ export type MaplibreProps = Partial & interactiveLayerIds?: string[]; /** CSS cursor */ cursor?: string; + + /** Minimum zoom available to the map. + * @default 0 + */ + minZoom?: number; + /** Maximum zoom available to the map. + * @default 22 + */ + maxZoom?: number; + /** Minimum pitch available to the map. + * @default 0 + */ + minPitch?: number; + /** Maximum pitch available to the map. + * @default 85 + */ + maxPitch?: number; + /** Bounds of the map. + * @default [-180, -85.051129, 180, 85.051129] + */ + maxBounds?: [number, number, number, number]; + /** Whether to render copies of the world or not. + * @default true + */ + renderWorldCopies?: boolean; }; const DEFAULT_STYLE = {version: 8, sources: {}, layers: []} as StyleSpecification; @@ -138,15 +168,8 @@ const otherEvents = { sourcedata: 'onSourceData', error: 'onError' }; -const settingNames = [ - 'minZoom', - 'maxZoom', - 'minPitch', - 'maxPitch', - 'maxBounds', - 'projection', - 'renderWorldCopies' -]; +const constraintNames = ['minZoom', 'maxZoom', 'minPitch', 'maxPitch'] as const; +const settingNames = [...constraintNames, 'maxBounds', 'projection', 'renderWorldCopies'] as const; const handlerNames = [ 'scrollZoom', 'boxZoom', @@ -414,6 +437,38 @@ export default class Maplibre { return false; } + /* Update camera constraints to match props + @param {object} nextProps + @param {object} currProps + @returns {bool} true if anything is changed + */ + private _updateConstraints(nextProps: MaplibreProps, currProps: MaplibreProps): boolean { + const didUpdateZoom = updateZoomConstraint( + this._map, + { + min: nextProps.minZoom ?? DEFAULT_SETTINGS.minZoom, + max: nextProps.maxZoom ?? DEFAULT_SETTINGS.maxZoom + }, + { + min: currProps.minZoom ?? DEFAULT_SETTINGS.minZoom, + max: currProps.maxZoom ?? DEFAULT_SETTINGS.maxZoom + } + ); + const didUpdatePitch = updatePitchConstraint( + this._map, + { + min: nextProps.minPitch ?? DEFAULT_SETTINGS.minPitch, + max: nextProps.maxPitch ?? DEFAULT_SETTINGS.maxPitch + }, + { + min: currProps.minPitch ?? DEFAULT_SETTINGS.minPitch, + max: currProps.maxPitch ?? DEFAULT_SETTINGS.maxPitch + } + ); + + return didUpdateZoom || didUpdatePitch; + } + /* Update camera constraints and projection settings to match props @param {object} nextProps @param {object} currProps @@ -421,18 +476,23 @@ export default class Maplibre { */ private _updateSettings(nextProps: MaplibreProps, currProps: MaplibreProps): boolean { const map = this._map; - let changed = false; + let settingsChanged = false; for (const propName of settingNames) { - const propPresent = propName in nextProps || propName in currProps; + if (constraintNames.includes(propName as (typeof constraintNames)[number])) { + // eslint-disable-next-line no-continue + continue; + } + const propPresent = propName in nextProps || propName in currProps; if (propPresent && !deepEqual(nextProps[propName], currProps[propName])) { - changed = true; + settingsChanged = true; const nextValue = propName in nextProps ? nextProps[propName] : DEFAULT_SETTINGS[propName]; const setter = map[`set${propName[0].toUpperCase()}${propName.slice(1)}`]; setter?.call(map, nextValue); } } - return changed; + const constraintsChanged = this._updateConstraints(nextProps, currProps); + return settingsChanged || constraintsChanged; } /* Update map style to match props */ diff --git a/modules/react-maplibre/src/utils/transform.ts b/modules/react-maplibre/src/utils/transform.ts index bd7f74880..a317402f6 100644 --- a/modules/react-maplibre/src/utils/transform.ts +++ b/modules/react-maplibre/src/utils/transform.ts @@ -1,6 +1,7 @@ import type {MaplibreProps} from '../maplibre/maplibre'; import type {ViewState} from '../types/common'; import type {TransformLike} from '../types/internal'; +import type {MapInstance} from '../types/lib'; import {deepEqual} from './deep-equal'; /** @@ -56,3 +57,79 @@ export function applyViewStateToTransform( } return changes; } + +/** + * Update zoom constraints to match props by calling + * `setMinZoom` and `setMaxZoom` in the right order + * @param {object} nextRange + * @param {object} currRange + **/ +export function updateZoomConstraint( + map: MapInstance, + nextRange: {min: number; max: number}, + currentRange: {min: number; max: number} +): boolean { + if (nextRange.min === currentRange.min && nextRange.max === currentRange.max) { + return false; + } + + // if moving up ie. 1 - 3 -> 5 - 10 + if (nextRange.min >= currentRange.min) { + if (nextRange.max !== currentRange.max) { + map.setMaxZoom(nextRange.max); + } + if (nextRange.min !== currentRange.min) { + map.setMinZoom(nextRange.min); + } + } + + // if moving down ie. 5 - 10 -> 1 - 3 + if (nextRange.min < currentRange.min) { + if (nextRange.min !== currentRange.min) { + map.setMinZoom(nextRange.min); + } + if (nextRange.max !== currentRange.max) { + map.setMaxZoom(nextRange.max); + } + } + + return true; +} + +/** + * Update pitch constraints to match props by calling + * `setMinPitch` and `setMaxPitch` in the right order + * @param {object} nextRange + * @param {object} currRange + **/ +export function updatePitchConstraint( + map: MapInstance, + nextRange: {min: number; max: number}, + currentRange: {min: number; max: number} +): boolean { + if (nextRange.min === currentRange.min && nextRange.max === currentRange.max) { + return false; + } + + // if moving up ie. 1 - 3 -> 5 - 10 + if (nextRange.min >= currentRange.min) { + if (nextRange.max !== currentRange.max) { + map.setMaxPitch(nextRange.max); + } + if (nextRange.min !== currentRange.min) { + map.setMinPitch(nextRange.min); + } + } + + // if moving down ie. 5 - 10 -> 1 - 3 + if (nextRange.min < currentRange.min) { + if (nextRange.min !== currentRange.min) { + map.setMinPitch(nextRange.min); + } + if (nextRange.max !== currentRange.max) { + map.setMaxPitch(nextRange.max); + } + } + + return true; +} diff --git a/modules/react-maplibre/test/utils/transform.spec.js b/modules/react-maplibre/test/utils/transform.spec.js index 25e575540..e9173627a 100644 --- a/modules/react-maplibre/test/utils/transform.spec.js +++ b/modules/react-maplibre/test/utils/transform.spec.js @@ -1,7 +1,9 @@ import test from 'tape-promise/tape'; import { transformToViewState, - applyViewStateToTransform + applyViewStateToTransform, + updateZoomConstraint, + updatePitchConstraint, } from '@vis.gl/react-maplibre/utils/transform'; import maplibregl from 'maplibre-gl'; @@ -64,3 +66,131 @@ test('applyViewStateToTransform', t => { t.end(); }); + +test('updateZoomConstraint', t => { + let first = null + let currentMinZoom = 0 + let currentMaxZoom = 0 + const map = { + setMinZoom: (nextMinZoom) => { + if (nextMinZoom > currentMaxZoom) { + throw new Error('Setting minZoom > maxZoom') + } + currentMinZoom = nextMinZoom + if (!first) { + first = 'min' + } + }, + setMaxZoom: (nextMaxZoom) => { + if (nextMaxZoom < currentMinZoom) { + throw new Error('Setting maxZoom < minZoom') + } + currentMaxZoom = nextMaxZoom + if (!first) { + first = 'max' + } + } + } + + currentMinZoom = 5 + currentMaxZoom = 10 + updateZoomConstraint(map, { min: 1, max: 3 }, { min: currentMinZoom, max: currentMaxZoom }); + t.equal(first, 'min', '5 - 10 -> 1 - 3, update min first') + first = null + + currentMinZoom = 1 + currentMaxZoom = 3 + updateZoomConstraint(map, { min: 5, max: 10 }, { min: currentMinZoom, max: currentMaxZoom }); + t.equal(first, 'max', '1 - 3 -> 5 - 10, update max first') + first = null + + currentMinZoom = 5 + currentMaxZoom = 18 + updateZoomConstraint(map, { min: 3, max: 22 }, { min: currentMinZoom, max: currentMaxZoom }); + t.equal(first, 'min', '5 - 18 -> 3 - 22, update min first') + first = null + + currentMinZoom = 5 + currentMaxZoom = 18 + updateZoomConstraint(map, { min: 3, max: 18 }, { min: currentMinZoom, max: currentMaxZoom }); + t.equal(first, 'min', '5 - 18 -> 3 - 18, update min first') + first = null + + currentMinZoom = 3 + currentMaxZoom = 22 + updateZoomConstraint(map, { min: 5, max: 18 }, { min: currentMinZoom, max: currentMaxZoom }); + t.equal(first, 'max', '3 - 22 -> 5 - 18, update max first') + first = null + + currentMinZoom = 12 + currentMaxZoom = 22 + updateZoomConstraint(map, { min: 5, max: 10 }, { min: currentMinZoom, max: currentMaxZoom }); + t.equal(first, 'min', '12 - 22 -> 5 - 10, update min first') + first = null + + t.end(); +}); + +test('updatePitchConstraint', t => { + let first = null + let currentMinPitch = 0 + let currentMaxPitch = 0 + const map = { + setMinPitch: (nextMinPitch) => { + if (nextMinPitch > currentMaxPitch) { + throw new Error('Setting minPitch > maxPitch') + } + currentMinPitch = nextMinPitch + if (!first) { + first = 'min' + } + }, + setMaxPitch: (nextMaxPitch) => { + if (nextMaxPitch < currentMinPitch) { + throw new Error('Setting maxPitch < minPitch') + } + currentMaxPitch = nextMaxPitch + if (!first) { + first = 'max' + } + } + } + + currentMinPitch = 5 + currentMaxPitch = 10 + updatePitchConstraint(map, { min: 1, max: 3 }, { min: currentMinPitch, max: currentMaxPitch }); + t.equal(first, 'min', '5 - 10 -> 1 - 3, update min first') + first = null + + currentMinPitch = 1 + currentMaxPitch = 3 + updatePitchConstraint(map, { min: 5, max: 10 }, { min: currentMinPitch, max: currentMaxPitch }); + t.equal(first, 'max', '1 - 3 -> 5 - 10, update max first') + first = null + + currentMinPitch = 5 + currentMaxPitch = 18 + updatePitchConstraint(map, { min: 3, max: 22 }, { min: currentMinPitch, max: currentMaxPitch }); + t.equal(first, 'min', '5 - 18 -> 3 - 22, update min first') + first = null + + currentMinPitch = 5 + currentMaxPitch = 18 + updatePitchConstraint(map, { min: 3, max: 18 }, { min: currentMinPitch, max: currentMaxPitch }); + t.equal(first, 'min', '5 - 18 -> 3 - 18, update min first') + first = null + + currentMinPitch = 3 + currentMaxPitch = 22 + updatePitchConstraint(map, { min: 5, max: 18 }, { min: currentMinPitch, max: currentMaxPitch }); + t.equal(first, 'max', '3 - 22 -> 5 - 18, update max first') + first = null + + currentMinPitch = 12 + currentMaxPitch = 22 + updatePitchConstraint(map, { min: 5, max: 10 }, { min: currentMinPitch, max: currentMaxPitch }); + t.equal(first, 'min', '12 - 22 -> 5 - 10, update min first') + first = null + + t.end(); +});