diff --git a/docs/api-reference/maplibre/map.md b/docs/api-reference/maplibre/map.md index 6af3d63ef..04e9af602 100644 --- a/docs/api-reference/maplibre/map.md +++ b/docs/api-reference/maplibre/map.md @@ -129,29 +129,29 @@ Default: `null` The padding in pixels around the viewport. -#### `minZoom`: number {#minzoom} - -Default: `0` - -The minimum zoom level of the map (0-24). - -#### `maxZoom`: number {#maxzoom} - -Default: `22` - -The maximum zoom level of the map (0-24). - -#### `minPitch`: number {#minpitch} - -Default: `0` - -The minimum pitch of the map (0-85). - -#### `maxPitch`: number {#maxpitch} - -Default: `60` - -The maximum pitch of the map (0-85). +#### `minZoom`: number | null | undefined {#minzoom} + +Default: native MapLibre default (currently `-2`) + +The minimum zoom level of the map. Passing `null` or `undefined` resets it to the native MapLibre default. + +#### `maxZoom`: number | null | undefined {#maxzoom} + +Default: native MapLibre default (currently `22`) + +The maximum zoom level of the map. Passing `null` or `undefined` resets it to the native MapLibre default. + +#### `minPitch`: number | null | undefined {#minpitch} + +Default: native MapLibre default (currently `0`) + +The minimum pitch of the map. Passing `null` or `undefined` resets it to the native MapLibre default. + +#### `maxPitch`: number | null | undefined {#maxpitch} + +Default: native MapLibre default (currently `60`) + +The maximum pitch of the map. Passing `null` or `undefined` resets it to the native MapLibre default. #### `maxBounds`: [LngLatBoundsLike](./types.md#lnglatboundslike) {#maxbounds} diff --git a/modules/react-maplibre/src/maplibre/maplibre.ts b/modules/react-maplibre/src/maplibre/maplibre.ts index 3bef84e39..cd3e35205 100644 --- a/modules/react-maplibre/src/maplibre/maplibre.ts +++ b/modules/react-maplibre/src/maplibre/maplibre.ts @@ -25,7 +25,7 @@ import type { TerrainSpecification, ProjectionSpecification } from '../types/style-spec'; -import type {MapInstance} from '../types/lib'; +import type {MapInstance, MapOptions} from '../types/lib'; import type {CameraUpdateTransformFunction, MapEventType} from 'maplibre-gl'; import type { MapCallbacks, @@ -35,8 +35,24 @@ import type { MapMouseEvent } from '../types/events'; +const cameraConstraintNames = ['minZoom', 'maxZoom', 'minPitch', 'maxPitch'] as const; + +type ReactiveMapOptions = Pick< + MapOptions, + (typeof cameraConstraintNames)[number] | 'maxBounds' | 'renderWorldCopies' +>; + +function removeNullishCameraConstraints(options: ReactiveMapOptions): void { + for (const propName of cameraConstraintNames) { + if (options[propName] === null || options[propName] === undefined) { + delete options[propName]; + } + } +} + export type MaplibreProps = Partial & - MapCallbacks & { + MapCallbacks & + ReactiveMapOptions & { /** Camera options used when constructing the Map instance */ initialViewState?: Partial & { /** The initial bounds of the map. If bounds is specified, it overrides longitude, latitude and zoom options. */ @@ -83,40 +99,11 @@ 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; const DEFAULT_SETTINGS = { - minZoom: 0, - maxZoom: 22, - minPitch: 0, - maxPitch: 85, maxBounds: [-180, -85.051129, 180, 85.051129], projection: 'mercator', renderWorldCopies: true @@ -302,6 +289,7 @@ export default class Maplibre { container, style: normalizeStyle(mapStyle) }; + removeNullishCameraConstraints(mapOptions); const viewState = mapOptions.initialViewState || mapOptions.viewState || mapOptions; Object.assign(mapOptions, { @@ -457,23 +445,23 @@ export default class Maplibre { const didUpdateZoom = updateZoomConstraint( this._map, { - min: nextProps.minZoom ?? DEFAULT_SETTINGS.minZoom, - max: nextProps.maxZoom ?? DEFAULT_SETTINGS.maxZoom + min: nextProps.minZoom, + max: nextProps.maxZoom }, { - min: currProps.minZoom ?? DEFAULT_SETTINGS.minZoom, - max: currProps.maxZoom ?? DEFAULT_SETTINGS.maxZoom + min: currProps.minZoom, + max: currProps.maxZoom } ); const didUpdatePitch = updatePitchConstraint( this._map, { - min: nextProps.minPitch ?? DEFAULT_SETTINGS.minPitch, - max: nextProps.maxPitch ?? DEFAULT_SETTINGS.maxPitch + min: nextProps.minPitch, + max: nextProps.maxPitch }, { - min: currProps.minPitch ?? DEFAULT_SETTINGS.minPitch, - max: currProps.maxPitch ?? DEFAULT_SETTINGS.maxPitch + min: currProps.minPitch, + max: currProps.maxPitch } ); diff --git a/modules/react-maplibre/src/utils/transform.ts b/modules/react-maplibre/src/utils/transform.ts index 3f7d2150f..5fb1d1099 100644 --- a/modules/react-maplibre/src/utils/transform.ts +++ b/modules/react-maplibre/src/utils/transform.ts @@ -83,32 +83,48 @@ export function applyViewStateToTransform( * @param setMin - setter for the minimum value * @param setMax - setter for the maximum value */ +type ConstraintValue = number | null | undefined; + +type ConstraintRange = {min: ConstraintValue; max: ConstraintValue}; + +function isConstraintReset(value: ConstraintValue): value is null | undefined { + return value === null || value === undefined; +} + +function sameConstraintValue(a: ConstraintValue, b: ConstraintValue): boolean { + return (isConstraintReset(a) && isConstraintReset(b)) || a === b; +} + function updateConstraint( - nextRange: {min: number; max: number}, - currentRange: {min: number; max: number}, - setMin: (v: number) => void, - setMax: (v: number) => void + nextRange: ConstraintRange, + currentRange: ConstraintRange, + getCurrentMin: () => number, + setMin: (v?: number | null) => void, + setMax: (v?: number | null) => void ): boolean { - if (nextRange.min === currentRange.min && nextRange.max === currentRange.max) { + const minChanged = !sameConstraintValue(nextRange.min, currentRange.min); + const maxChanged = !sameConstraintValue(nextRange.max, currentRange.max); + + if (!minChanged && !maxChanged) { 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) { + // When lowering or resetting min, update it first to make room. + // Otherwise update max first before raising min. + if (isConstraintReset(nextRange.min) || nextRange.min < getCurrentMin()) { + if (minChanged) { setMin(nextRange.min); } - } else { - // When moving down (min decreasing), update min first to make room - if (nextRange.min !== currentRange.min) { - setMin(nextRange.min); + if (maxChanged) { + setMax(nextRange.max); } - if (nextRange.max !== currentRange.max) { + } else { + if (maxChanged) { setMax(nextRange.max); } + if (minChanged) { + setMin(nextRange.min); + } } return true; @@ -116,12 +132,13 @@ function updateConstraint( export function updateZoomConstraint( map: MapInstance, - nextRange: {min: number; max: number}, - currentRange: {min: number; max: number} + nextRange: ConstraintRange, + currentRange: ConstraintRange ): boolean { return updateConstraint( nextRange, currentRange, + () => map.getMinZoom(), v => map.setMinZoom(v), v => map.setMaxZoom(v) ); @@ -129,12 +146,13 @@ export function updateZoomConstraint( export function updatePitchConstraint( map: MapInstance, - nextRange: {min: number; max: number}, - currentRange: {min: number; max: number} + nextRange: ConstraintRange, + currentRange: ConstraintRange ): boolean { return updateConstraint( nextRange, currentRange, + () => map.getMinPitch(), v => map.setMinPitch(v), v => map.setMaxPitch(v) ); diff --git a/modules/react-maplibre/test/components/map.spec.jsx b/modules/react-maplibre/test/components/map.spec.jsx index 5394aed16..f9982c904 100644 --- a/modules/react-maplibre/test/components/map.spec.jsx +++ b/modules/react-maplibre/test/components/map.spec.jsx @@ -4,8 +4,18 @@ import * as React from 'react'; import {createRoot} from 'react-dom/client'; import {act} from 'react-dom/test-utils'; import {Map} from '@vis.gl/react-maplibre'; +import Maplibre from '../../src/maplibre/maplibre'; import {waitForMapLoad, actUntil} from '../utils/test-utils'; +function getCameraConstraints(map) { + return { + minZoom: map.getMinZoom(), + maxZoom: map.getMaxZoom(), + minPitch: map.getMinPitch(), + maxPitch: map.getMaxPitch() + }; +} + test('Map', async () => { expect(Map, 'Map is defined').toBeTruthy(); @@ -45,6 +55,107 @@ test('Map', async () => { await act(() => root.unmount()); }); +test('Map resets camera constraints to MapLibre defaults', async () => { + const root = createRoot(document.createElement('div')); + const mapRef = {current: null}; + + await act(() => root.render()); + await waitForMapLoad(mapRef); + + const defaults = getCameraConstraints(mapRef.current); + + await act(() => + root.render() + ); + + expect(getCameraConstraints(mapRef.current)).toEqual({ + minZoom: 2, + maxZoom: 10, + minPitch: 5, + maxPitch: 40 + }); + + await act(() => + root.render() + ); + + expect(getCameraConstraints(mapRef.current)).toEqual(defaults); + + await act(() => + root.render() + ); + await act(() => + root.render( + + ) + ); + + expect(getCameraConstraints(mapRef.current)).toEqual(defaults); + + await act(() => + root.render() + ); + await act(() => root.render()); + + expect(getCameraConstraints(mapRef.current)).toEqual(defaults); + + await act(() => root.unmount()); + + for (const resetValue of [null, undefined]) { + const initialRoot = createRoot(document.createElement('div')); + const initialMapRef = {current: null}; + await act(() => + initialRoot.render( + + ) + ); + await waitForMapLoad(initialMapRef); + expect(getCameraConstraints(initialMapRef.current)).toEqual(defaults); + await act(() => initialRoot.unmount()); + } +}); + +test('Map resets camera constraints when reusing a map', async () => { + const firstRoot = createRoot(document.createElement('div')); + const firstMapRef = {current: null}; + + await act(() => firstRoot.render()); + await waitForMapLoad(firstMapRef); + + const defaults = getCameraConstraints(firstMapRef.current); + + await act(() => + firstRoot.render( + + ) + ); + await act(() => firstRoot.unmount()); + + const secondRoot = createRoot(document.createElement('div')); + const secondMapRef = {current: null}; + await act(() => secondRoot.render()); + await waitForMapLoad(secondMapRef); + + expect(getCameraConstraints(secondMapRef.current)).toEqual(defaults); + + await act(() => secondRoot.unmount()); + while (Maplibre.savedMaps.length > 0) { + Maplibre.savedMaps.pop().destroy(); + } +}); + test('Map#uncontrolled', async () => { await actUntil(resolveTest => { const root = createRoot(document.createElement('div')); diff --git a/modules/react-maplibre/test/utils/transform.spec.js b/modules/react-maplibre/test/utils/transform.spec.js index 7533a13ab..a9cfb1b04 100644 --- a/modules/react-maplibre/test/utils/transform.spec.js +++ b/modules/react-maplibre/test/utils/transform.spec.js @@ -104,12 +104,15 @@ test('applyViewStateToTransform', () => { expect(changed, 'nothing changed').toEqual({}); }); -function createConstraintMap(setMinName, setMaxName) { +function createConstraintMap(setMinName, setMaxName, defaultRange) { let first = null; let currentMin = 0; let currentMax = 0; + const getMinName = setMinName.replace('set', 'get'); const map = { + [getMinName]: () => currentMin, [setMinName]: nextMin => { + nextMin ??= defaultRange.min; if (nextMin > currentMax) { throw new Error(`Setting ${setMinName} (${nextMin}) > current max (${currentMax})`); } @@ -119,6 +122,7 @@ function createConstraintMap(setMinName, setMaxName) { } }, [setMaxName]: nextMax => { + nextMax ??= defaultRange.max; if (nextMax < currentMin) { throw new Error(`Setting ${setMaxName} (${nextMax}) < current min (${currentMin})`); } @@ -141,8 +145,8 @@ function createConstraintMap(setMinName, setMaxName) { }; } -function testConstraintUpdate(updateFn, setMinName, setMaxName, label) { - const helper = createConstraintMap(setMinName, setMaxName); +function testConstraintUpdate(updateFn, setMinName, setMaxName, label, defaultRange) { + const helper = createConstraintMap(setMinName, setMaxName, defaultRange); // Range shifting down helper.reset(5, 10); @@ -194,6 +198,25 @@ function testConstraintUpdate(updateFn, setMinName, setMaxName, label) { updateFn(helper.map, {min: 3, max: 8}, {min: 6, max: 10}); expect(helper.getFirst(), `${label}: 6 - 10 -> 3 - 8, partial overlap shifting down`).toBe('min'); + // Resetting max may lower it below the current min + helper.reset(defaultRange.max + 1, defaultRange.max + 2); + updateFn( + helper.map, + {min: defaultRange.max - 2, max: undefined}, + {min: defaultRange.max + 1, max: defaultRange.max + 2} + ); + expect(helper.getFirst(), `${label}: lower min before resetting max`).toBe('min'); + + // All nullable forms represent the same native reset + helper.reset(defaultRange.min, defaultRange.max); + const nullableChanged = updateFn( + helper.map, + {min: null, max: undefined}, + {min: undefined, max: null} + ); + expect(nullableChanged, `${label}: nullable reset forms are equivalent`).toBe(false); + expect(helper.getFirst(), `${label}: nullable reset forms do not call setters`).toBe(null); + // No change returns false helper.reset(3, 10); const changed = updateFn(helper.map, {min: 3, max: 10}, {min: 3, max: 10}); @@ -201,9 +224,15 @@ function testConstraintUpdate(updateFn, setMinName, setMaxName, label) { } test('updateZoomConstraint', () => { - testConstraintUpdate(updateZoomConstraint, 'setMinZoom', 'setMaxZoom', 'zoom'); + testConstraintUpdate(updateZoomConstraint, 'setMinZoom', 'setMaxZoom', 'zoom', { + min: -2, + max: 22 + }); }); test('updatePitchConstraint', () => { - testConstraintUpdate(updatePitchConstraint, 'setMinPitch', 'setMaxPitch', 'pitch'); + testConstraintUpdate(updatePitchConstraint, 'setMinPitch', 'setMaxPitch', 'pitch', { + min: 0, + max: 60 + }); });