Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 23 additions & 23 deletions docs/api-reference/maplibre/map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
66 changes: 27 additions & 39 deletions modules/react-maplibre/src/maplibre/maplibre.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<ViewState> &
MapCallbacks & {
MapCallbacks &
ReactiveMapOptions & {
/** Camera options used when constructing the Map instance */
initialViewState?: Partial<ViewState> & {
/** The initial bounds of the map. If bounds is specified, it overrides longitude, latitude and zoom options. */
Expand Down Expand Up @@ -83,40 +99,11 @@ export type MaplibreProps = Partial<ViewState> &
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
Expand Down Expand Up @@ -302,6 +289,7 @@ export default class Maplibre {
container,
style: normalizeStyle(mapStyle)
};
removeNullishCameraConstraints(mapOptions);

const viewState = mapOptions.initialViewState || mapOptions.viewState || mapOptions;
Object.assign(mapOptions, {
Expand Down Expand Up @@ -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
}
);

Expand Down
58 changes: 38 additions & 20 deletions modules/react-maplibre/src/utils/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,58 +83,76 @@ 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;
}

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)
);
}

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)
);
Expand Down
111 changes: 111 additions & 0 deletions modules/react-maplibre/test/components/map.spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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(<Map ref={mapRef} />));
await waitForMapLoad(mapRef);

const defaults = getCameraConstraints(mapRef.current);

await act(() =>
root.render(<Map ref={mapRef} minZoom={2} maxZoom={10} minPitch={5} maxPitch={40} />)
);

expect(getCameraConstraints(mapRef.current)).toEqual({
minZoom: 2,
maxZoom: 10,
minPitch: 5,
maxPitch: 40
});

await act(() =>
root.render(<Map ref={mapRef} minZoom={null} maxZoom={null} minPitch={null} maxPitch={null} />)
);

expect(getCameraConstraints(mapRef.current)).toEqual(defaults);

await act(() =>
root.render(<Map ref={mapRef} minZoom={3} maxZoom={11} minPitch={6} maxPitch={41} />)
);
await act(() =>
root.render(
<Map
ref={mapRef}
minZoom={undefined}
maxZoom={undefined}
minPitch={undefined}
maxPitch={undefined}
/>
)
);

expect(getCameraConstraints(mapRef.current)).toEqual(defaults);

await act(() =>
root.render(<Map ref={mapRef} minZoom={4} maxZoom={12} minPitch={7} maxPitch={42} />)
);
await act(() => root.render(<Map ref={mapRef} />));

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(
<Map
ref={initialMapRef}
minZoom={resetValue}
maxZoom={resetValue}
minPitch={resetValue}
maxPitch={resetValue}
/>
)
);
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(<Map ref={firstMapRef} reuseMaps />));
await waitForMapLoad(firstMapRef);

const defaults = getCameraConstraints(firstMapRef.current);

await act(() =>
firstRoot.render(
<Map ref={firstMapRef} reuseMaps minZoom={2} maxZoom={10} minPitch={5} maxPitch={40} />
)
);
await act(() => firstRoot.unmount());

const secondRoot = createRoot(document.createElement('div'));
const secondMapRef = {current: null};
await act(() => secondRoot.render(<Map ref={secondMapRef} reuseMaps />));
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'));
Expand Down
Loading
Loading