Skip to content

Commit 1afe1a9

Browse files
authored
fix(maplibre): align map props with MapOptions (#2612)
1 parent f7629bb commit 1afe1a9

5 files changed

Lines changed: 233 additions & 87 deletions

File tree

‎docs/api-reference/maplibre/map.md‎

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -129,29 +129,29 @@ Default: `null`
129129

130130
The padding in pixels around the viewport.
131131

132-
#### `minZoom`: number {#minzoom}
133-
134-
Default: `0`
135-
136-
The minimum zoom level of the map (0-24).
137-
138-
#### `maxZoom`: number {#maxzoom}
139-
140-
Default: `22`
141-
142-
The maximum zoom level of the map (0-24).
143-
144-
#### `minPitch`: number {#minpitch}
145-
146-
Default: `0`
147-
148-
The minimum pitch of the map (0-85).
149-
150-
#### `maxPitch`: number {#maxpitch}
151-
152-
Default: `60`
153-
154-
The maximum pitch of the map (0-85).
132+
#### `minZoom`: number | null | undefined {#minzoom}
133+
134+
Default: native MapLibre default (currently `-2`)
135+
136+
The minimum zoom level of the map. Passing `null` or `undefined` resets it to the native MapLibre default.
137+
138+
#### `maxZoom`: number | null | undefined {#maxzoom}
139+
140+
Default: native MapLibre default (currently `22`)
141+
142+
The maximum zoom level of the map. Passing `null` or `undefined` resets it to the native MapLibre default.
143+
144+
#### `minPitch`: number | null | undefined {#minpitch}
145+
146+
Default: native MapLibre default (currently `0`)
147+
148+
The minimum pitch of the map. Passing `null` or `undefined` resets it to the native MapLibre default.
149+
150+
#### `maxPitch`: number | null | undefined {#maxpitch}
151+
152+
Default: native MapLibre default (currently `60`)
153+
154+
The maximum pitch of the map. Passing `null` or `undefined` resets it to the native MapLibre default.
155155

156156
#### `maxBounds`: [LngLatBoundsLike](./types.md#lnglatboundslike) {#maxbounds}
157157

‎modules/react-maplibre/src/maplibre/maplibre.ts‎

Lines changed: 27 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import type {
2525
TerrainSpecification,
2626
ProjectionSpecification
2727
} from '../types/style-spec';
28-
import type {MapInstance} from '../types/lib';
28+
import type {MapInstance, MapOptions} from '../types/lib';
2929
import type {CameraUpdateTransformFunction, MapEventType} from 'maplibre-gl';
3030
import type {
3131
MapCallbacks,
@@ -35,8 +35,24 @@ import type {
3535
MapMouseEvent
3636
} from '../types/events';
3737

38+
const cameraConstraintNames = ['minZoom', 'maxZoom', 'minPitch', 'maxPitch'] as const;
39+
40+
type ReactiveMapOptions = Pick<
41+
MapOptions,
42+
(typeof cameraConstraintNames)[number] | 'maxBounds' | 'renderWorldCopies'
43+
>;
44+
45+
function removeNullishCameraConstraints(options: ReactiveMapOptions): void {
46+
for (const propName of cameraConstraintNames) {
47+
if (options[propName] === null || options[propName] === undefined) {
48+
delete options[propName];
49+
}
50+
}
51+
}
52+
3853
export type MaplibreProps = Partial<ViewState> &
39-
MapCallbacks & {
54+
MapCallbacks &
55+
ReactiveMapOptions & {
4056
/** Camera options used when constructing the Map instance */
4157
initialViewState?: Partial<ViewState> & {
4258
/** 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<ViewState> &
8399
interactiveLayerIds?: string[];
84100
/** CSS cursor */
85101
cursor?: string;
86-
87-
/** Minimum zoom available to the map.
88-
* @default 0
89-
*/
90-
minZoom?: number;
91-
/** Maximum zoom available to the map.
92-
* @default 22
93-
*/
94-
maxZoom?: number;
95-
/** Minimum pitch available to the map.
96-
* @default 0
97-
*/
98-
minPitch?: number;
99-
/** Maximum pitch available to the map.
100-
* @default 85
101-
*/
102-
maxPitch?: number;
103-
/** Bounds of the map.
104-
* @default [-180, -85.051129, 180, 85.051129]
105-
*/
106-
maxBounds?: [number, number, number, number];
107-
/** Whether to render copies of the world or not.
108-
* @default true
109-
*/
110-
renderWorldCopies?: boolean;
111102
};
112103

113104
const DEFAULT_STYLE = {version: 8, sources: {}, layers: []} as StyleSpecification;
114105

115106
const DEFAULT_SETTINGS = {
116-
minZoom: 0,
117-
maxZoom: 22,
118-
minPitch: 0,
119-
maxPitch: 85,
120107
maxBounds: [-180, -85.051129, 180, 85.051129],
121108
projection: 'mercator',
122109
renderWorldCopies: true
@@ -302,6 +289,7 @@ export default class Maplibre {
302289
container,
303290
style: normalizeStyle(mapStyle)
304291
};
292+
removeNullishCameraConstraints(mapOptions);
305293

306294
const viewState = mapOptions.initialViewState || mapOptions.viewState || mapOptions;
307295
Object.assign(mapOptions, {
@@ -457,23 +445,23 @@ export default class Maplibre {
457445
const didUpdateZoom = updateZoomConstraint(
458446
this._map,
459447
{
460-
min: nextProps.minZoom ?? DEFAULT_SETTINGS.minZoom,
461-
max: nextProps.maxZoom ?? DEFAULT_SETTINGS.maxZoom
448+
min: nextProps.minZoom,
449+
max: nextProps.maxZoom
462450
},
463451
{
464-
min: currProps.minZoom ?? DEFAULT_SETTINGS.minZoom,
465-
max: currProps.maxZoom ?? DEFAULT_SETTINGS.maxZoom
452+
min: currProps.minZoom,
453+
max: currProps.maxZoom
466454
}
467455
);
468456
const didUpdatePitch = updatePitchConstraint(
469457
this._map,
470458
{
471-
min: nextProps.minPitch ?? DEFAULT_SETTINGS.minPitch,
472-
max: nextProps.maxPitch ?? DEFAULT_SETTINGS.maxPitch
459+
min: nextProps.minPitch,
460+
max: nextProps.maxPitch
473461
},
474462
{
475-
min: currProps.minPitch ?? DEFAULT_SETTINGS.minPitch,
476-
max: currProps.maxPitch ?? DEFAULT_SETTINGS.maxPitch
463+
min: currProps.minPitch,
464+
max: currProps.maxPitch
477465
}
478466
);
479467

‎modules/react-maplibre/src/utils/transform.ts‎

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -83,58 +83,76 @@ export function applyViewStateToTransform(
8383
* @param setMin - setter for the minimum value
8484
* @param setMax - setter for the maximum value
8585
*/
86+
type ConstraintValue = number | null | undefined;
87+
88+
type ConstraintRange = {min: ConstraintValue; max: ConstraintValue};
89+
90+
function isConstraintReset(value: ConstraintValue): value is null | undefined {
91+
return value === null || value === undefined;
92+
}
93+
94+
function sameConstraintValue(a: ConstraintValue, b: ConstraintValue): boolean {
95+
return (isConstraintReset(a) && isConstraintReset(b)) || a === b;
96+
}
97+
8698
function updateConstraint(
87-
nextRange: {min: number; max: number},
88-
currentRange: {min: number; max: number},
89-
setMin: (v: number) => void,
90-
setMax: (v: number) => void
99+
nextRange: ConstraintRange,
100+
currentRange: ConstraintRange,
101+
getCurrentMin: () => number,
102+
setMin: (v?: number | null) => void,
103+
setMax: (v?: number | null) => void
91104
): boolean {
92-
if (nextRange.min === currentRange.min && nextRange.max === currentRange.max) {
105+
const minChanged = !sameConstraintValue(nextRange.min, currentRange.min);
106+
const maxChanged = !sameConstraintValue(nextRange.max, currentRange.max);
107+
108+
if (!minChanged && !maxChanged) {
93109
return false;
94110
}
95111

96-
// When moving up (min increasing), update max first to make room
97-
if (nextRange.min >= currentRange.min) {
98-
if (nextRange.max !== currentRange.max) {
99-
setMax(nextRange.max);
100-
}
101-
if (nextRange.min !== currentRange.min) {
112+
// When lowering or resetting min, update it first to make room.
113+
// Otherwise update max first before raising min.
114+
if (isConstraintReset(nextRange.min) || nextRange.min < getCurrentMin()) {
115+
if (minChanged) {
102116
setMin(nextRange.min);
103117
}
104-
} else {
105-
// When moving down (min decreasing), update min first to make room
106-
if (nextRange.min !== currentRange.min) {
107-
setMin(nextRange.min);
118+
if (maxChanged) {
119+
setMax(nextRange.max);
108120
}
109-
if (nextRange.max !== currentRange.max) {
121+
} else {
122+
if (maxChanged) {
110123
setMax(nextRange.max);
111124
}
125+
if (minChanged) {
126+
setMin(nextRange.min);
127+
}
112128
}
113129

114130
return true;
115131
}
116132

117133
export function updateZoomConstraint(
118134
map: MapInstance,
119-
nextRange: {min: number; max: number},
120-
currentRange: {min: number; max: number}
135+
nextRange: ConstraintRange,
136+
currentRange: ConstraintRange
121137
): boolean {
122138
return updateConstraint(
123139
nextRange,
124140
currentRange,
141+
() => map.getMinZoom(),
125142
v => map.setMinZoom(v),
126143
v => map.setMaxZoom(v)
127144
);
128145
}
129146

130147
export function updatePitchConstraint(
131148
map: MapInstance,
132-
nextRange: {min: number; max: number},
133-
currentRange: {min: number; max: number}
149+
nextRange: ConstraintRange,
150+
currentRange: ConstraintRange
134151
): boolean {
135152
return updateConstraint(
136153
nextRange,
137154
currentRange,
155+
() => map.getMinPitch(),
138156
v => map.setMinPitch(v),
139157
v => map.setMaxPitch(v)
140158
);

‎modules/react-maplibre/test/components/map.spec.jsx‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,18 @@ import * as React from 'react';
44
import {createRoot} from 'react-dom/client';
55
import {act} from 'react-dom/test-utils';
66
import {Map} from '@vis.gl/react-maplibre';
7+
import Maplibre from '../../src/maplibre/maplibre';
78
import {waitForMapLoad, actUntil} from '../utils/test-utils';
89

10+
function getCameraConstraints(map) {
11+
return {
12+
minZoom: map.getMinZoom(),
13+
maxZoom: map.getMaxZoom(),
14+
minPitch: map.getMinPitch(),
15+
maxPitch: map.getMaxPitch()
16+
};
17+
}
18+
919
test('Map', async () => {
1020
expect(Map, 'Map is defined').toBeTruthy();
1121

@@ -45,6 +55,107 @@ test('Map', async () => {
4555
await act(() => root.unmount());
4656
});
4757

58+
test('Map resets camera constraints to MapLibre defaults', async () => {
59+
const root = createRoot(document.createElement('div'));
60+
const mapRef = {current: null};
61+
62+
await act(() => root.render(<Map ref={mapRef} />));
63+
await waitForMapLoad(mapRef);
64+
65+
const defaults = getCameraConstraints(mapRef.current);
66+
67+
await act(() =>
68+
root.render(<Map ref={mapRef} minZoom={2} maxZoom={10} minPitch={5} maxPitch={40} />)
69+
);
70+
71+
expect(getCameraConstraints(mapRef.current)).toEqual({
72+
minZoom: 2,
73+
maxZoom: 10,
74+
minPitch: 5,
75+
maxPitch: 40
76+
});
77+
78+
await act(() =>
79+
root.render(<Map ref={mapRef} minZoom={null} maxZoom={null} minPitch={null} maxPitch={null} />)
80+
);
81+
82+
expect(getCameraConstraints(mapRef.current)).toEqual(defaults);
83+
84+
await act(() =>
85+
root.render(<Map ref={mapRef} minZoom={3} maxZoom={11} minPitch={6} maxPitch={41} />)
86+
);
87+
await act(() =>
88+
root.render(
89+
<Map
90+
ref={mapRef}
91+
minZoom={undefined}
92+
maxZoom={undefined}
93+
minPitch={undefined}
94+
maxPitch={undefined}
95+
/>
96+
)
97+
);
98+
99+
expect(getCameraConstraints(mapRef.current)).toEqual(defaults);
100+
101+
await act(() =>
102+
root.render(<Map ref={mapRef} minZoom={4} maxZoom={12} minPitch={7} maxPitch={42} />)
103+
);
104+
await act(() => root.render(<Map ref={mapRef} />));
105+
106+
expect(getCameraConstraints(mapRef.current)).toEqual(defaults);
107+
108+
await act(() => root.unmount());
109+
110+
for (const resetValue of [null, undefined]) {
111+
const initialRoot = createRoot(document.createElement('div'));
112+
const initialMapRef = {current: null};
113+
await act(() =>
114+
initialRoot.render(
115+
<Map
116+
ref={initialMapRef}
117+
minZoom={resetValue}
118+
maxZoom={resetValue}
119+
minPitch={resetValue}
120+
maxPitch={resetValue}
121+
/>
122+
)
123+
);
124+
await waitForMapLoad(initialMapRef);
125+
expect(getCameraConstraints(initialMapRef.current)).toEqual(defaults);
126+
await act(() => initialRoot.unmount());
127+
}
128+
});
129+
130+
test('Map resets camera constraints when reusing a map', async () => {
131+
const firstRoot = createRoot(document.createElement('div'));
132+
const firstMapRef = {current: null};
133+
134+
await act(() => firstRoot.render(<Map ref={firstMapRef} reuseMaps />));
135+
await waitForMapLoad(firstMapRef);
136+
137+
const defaults = getCameraConstraints(firstMapRef.current);
138+
139+
await act(() =>
140+
firstRoot.render(
141+
<Map ref={firstMapRef} reuseMaps minZoom={2} maxZoom={10} minPitch={5} maxPitch={40} />
142+
)
143+
);
144+
await act(() => firstRoot.unmount());
145+
146+
const secondRoot = createRoot(document.createElement('div'));
147+
const secondMapRef = {current: null};
148+
await act(() => secondRoot.render(<Map ref={secondMapRef} reuseMaps />));
149+
await waitForMapLoad(secondMapRef);
150+
151+
expect(getCameraConstraints(secondMapRef.current)).toEqual(defaults);
152+
153+
await act(() => secondRoot.unmount());
154+
while (Maplibre.savedMaps.length > 0) {
155+
Maplibre.savedMaps.pop().destroy();
156+
}
157+
});
158+
48159
test('Map#uncontrolled', async () => {
49160
await actUntil(resolveTest => {
50161
const root = createRoot(document.createElement('div'));

0 commit comments

Comments
 (0)