diff --git a/modules/react-maplibre/src/maplibre/maplibre.ts b/modules/react-maplibre/src/maplibre/maplibre.ts index fa16e53ad..f4656dab6 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,7 @@ const otherEvents = { sourcedata: 'onSourceData', error: 'onError' }; -const settingNames = [ - 'minZoom', - 'maxZoom', - 'minPitch', - 'maxPitch', - 'maxBounds', - 'projection', - 'renderWorldCopies' -]; +const settingNames = ['maxBounds', 'projection', 'renderWorldCopies'] as const; const handlerNames = [ 'scrollZoom', 'boxZoom', @@ -414,6 +436,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 +475,18 @@ 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 (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..742302c7b 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,68 @@ export function applyViewStateToTransform( } return changes; } + +/** + * Update a min/max constraint pair in the right order to avoid + * temporarily setting min > max (which maplibre rejects). + * @param nextRange - the desired constraint range + * @param currentRange - the current constraint range + * @param setMin - setter for the minimum value + * @param setMax - setter for the maximum value + */ +function updateConstraint( + nextRange: {min: number; max: number}, + currentRange: {min: number; max: number}, + setMin: (v: number) => void, + setMax: (v: number) => void +): boolean { + if (nextRange.min === currentRange.min && nextRange.max === currentRange.max) { + return false; + } + + // When moving up (min increasing), update max first to make room + if (nextRange.min >= currentRange.min) { + if (nextRange.max !== currentRange.max) { + setMax(nextRange.max); + } + if (nextRange.min !== currentRange.min) { + setMin(nextRange.min); + } + } else { + // When moving down (min decreasing), update min first to make room + if (nextRange.min !== currentRange.min) { + setMin(nextRange.min); + } + if (nextRange.max !== currentRange.max) { + setMax(nextRange.max); + } + } + + return true; +} + +export function updateZoomConstraint( + map: MapInstance, + nextRange: {min: number; max: number}, + currentRange: {min: number; max: number} +): boolean { + return updateConstraint( + nextRange, + currentRange, + v => map.setMinZoom(v), + v => map.setMaxZoom(v) + ); +} + +export function updatePitchConstraint( + map: MapInstance, + nextRange: {min: number; max: number}, + currentRange: {min: number; max: number} +): boolean { + return updateConstraint( + nextRange, + currentRange, + v => map.setMinPitch(v), + v => map.setMaxPitch(v) + ); +} diff --git a/modules/react-maplibre/test/utils/transform.spec.js b/modules/react-maplibre/test/utils/transform.spec.js index 25e575540..506dd0acd 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,109 @@ test('applyViewStateToTransform', t => { t.end(); }); + +function createConstraintMap(setMinName, setMaxName) { + let first = null; + let currentMin = 0; + let currentMax = 0; + const map = { + [setMinName]: nextMin => { + if (nextMin > currentMax) { + throw new Error(`Setting ${setMinName} (${nextMin}) > current max (${currentMax})`); + } + currentMin = nextMin; + if (!first) { + first = 'min'; + } + }, + [setMaxName]: nextMax => { + if (nextMax < currentMin) { + throw new Error(`Setting ${setMaxName} (${nextMax}) < current min (${currentMin})`); + } + currentMax = nextMax; + if (!first) { + first = 'max'; + } + } + }; + return { + map, + reset(min, max) { + currentMin = min; + currentMax = max; + first = null; + }, + getFirst() { + return first; + } + }; +} + +function testConstraintUpdate(t, updateFn, setMinName, setMaxName, label) { + const helper = createConstraintMap(setMinName, setMaxName); + + // Range shifting down + helper.reset(5, 10); + updateFn(helper.map, {min: 1, max: 3}, {min: 5, max: 10}); + t.equal(helper.getFirst(), 'min', `${label}: 5 - 10 -> 1 - 3, update min first`); + + // Range shifting up + helper.reset(1, 3); + updateFn(helper.map, {min: 5, max: 10}, {min: 1, max: 3}); + t.equal(helper.getFirst(), 'max', `${label}: 1 - 3 -> 5 - 10, update max first`); + + // Range expanding + helper.reset(5, 18); + updateFn(helper.map, {min: 3, max: 22}, {min: 5, max: 18}); + t.equal(helper.getFirst(), 'min', `${label}: 5 - 18 -> 3 - 22, update min first`); + + // Only min changing (decreasing) + helper.reset(5, 18); + updateFn(helper.map, {min: 3, max: 18}, {min: 5, max: 18}); + t.equal(helper.getFirst(), 'min', `${label}: 5 - 18 -> 3 - 18, update min first`); + + // Range contracting + helper.reset(3, 22); + updateFn(helper.map, {min: 5, max: 18}, {min: 3, max: 22}); + t.equal(helper.getFirst(), 'max', `${label}: 3 - 22 -> 5 - 18, update max first`); + + // Range shifting down with high start + helper.reset(12, 22); + updateFn(helper.map, {min: 5, max: 10}, {min: 12, max: 22}); + t.equal(helper.getFirst(), 'min', `${label}: 12 - 22 -> 5 - 10, update min first`); + + // Locked to single value (min === max) + helper.reset(3, 10); + updateFn(helper.map, {min: 5, max: 5}, {min: 3, max: 10}); + t.equal(helper.getFirst(), 'max', `${label}: 3 - 10 -> 5 - 5, lock to single value`); + + // Unlock from single value + helper.reset(5, 5); + updateFn(helper.map, {min: 3, max: 10}, {min: 5, max: 5}); + t.equal(helper.getFirst(), 'min', `${label}: 5 - 5 -> 3 - 10, unlock from single value`); + + // Partial overlap (shifting up) + helper.reset(3, 8); + updateFn(helper.map, {min: 6, max: 10}, {min: 3, max: 8}); + t.equal(helper.getFirst(), 'max', `${label}: 3 - 8 -> 6 - 10, partial overlap shifting up`); + + // Partial overlap (shifting down) + helper.reset(6, 10); + updateFn(helper.map, {min: 3, max: 8}, {min: 6, max: 10}); + t.equal(helper.getFirst(), 'min', `${label}: 6 - 10 -> 3 - 8, partial overlap shifting down`); + + // No change returns false + helper.reset(3, 10); + const changed = updateFn(helper.map, {min: 3, max: 10}, {min: 3, max: 10}); + t.equal(changed, false, `${label}: no change returns false`); +} + +test('updateZoomConstraint', t => { + testConstraintUpdate(t, updateZoomConstraint, 'setMinZoom', 'setMaxZoom', 'zoom'); + t.end(); +}); + +test('updatePitchConstraint', t => { + testConstraintUpdate(t, updatePitchConstraint, 'setMinPitch', 'setMaxPitch', 'pitch'); + t.end(); +});