Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Draft] Allow underzoom and overpan of a single-copy world #4733

Draft
wants to merge 21 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1424249
Temporary ignore local data
larsmaxfield Aug 26, 2024
b3eb99c
Allow underzooming when renderWorldCopies = false whereby minZoom dic…
larsmaxfield Aug 26, 2024
87ce80c
Respect bounds for underzooming
larsmaxfield Aug 26, 2024
70c8d26
Comment explanation of underzoom mechanic
larsmaxfield Aug 26, 2024
8186f29
Merge pull request #31 from maplibre/main
larsmaxfield Aug 27, 2024
309873d
Remove temporary /data in .gitignore
larsmaxfield Aug 27, 2024
61eede3
Merge branch 'main' into feature/zoom-out-single-world
larsmaxfield Aug 27, 2024
24f74ac
Temporary ignore test/data
larsmaxfield Aug 27, 2024
8b71f9f
In-progress: New underzoom design with two settings for zoom and pan
larsmaxfield Aug 27, 2024
b40a54e
Temporary (?) underzoom examples
larsmaxfield Aug 27, 2024
5a86b4c
Redo underzoom design with user variables underzoomScale and overpanR…
larsmaxfield Sep 18, 2024
a26ecd2
Merge branch 'maplibre:main' into feature/zoom-out-single-world
larsmaxfield Sep 18, 2024
0cf3cd2
Set default overpan ratios to 0.0; add `this.` placeholders for user …
larsmaxfield Sep 18, 2024
9b28b15
Change underzoom source to OSM; border line outside the bounds so the…
larsmaxfield Sep 20, 2024
1262dfc
Add underzoom input values to map options; docs, defaults NOT complete
larsmaxfield Sep 20, 2024
e0e73fb
Interactive unbounded underzoom example
larsmaxfield Sep 20, 2024
f843bf1
Update tall and wide underzoom examples with interactive inputs
larsmaxfield Sep 20, 2024
e580fca
Accurate underzoom example title and meta
larsmaxfield Sep 20, 2024
08ee717
Rename to underzoom and overpan as percentages; fix lint
larsmaxfield Sep 21, 2024
55fa1ea
Merge branch 'maplibre:main' into feature/zoom-out-single-world
larsmaxfield Sep 21, 2024
c12227c
Remove temporary .gitignore /test/data
larsmaxfield Sep 21, 2024
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
110 changes: 99 additions & 11 deletions src/geo/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export class Transform {
_zoom: number;
_unmodified: boolean;
_renderWorldCopies: boolean;
_allowUnderzoom: boolean;
_underzoom: number;
_overpan: number;
_minZoom: number;
_maxZoom: number;
_minPitch: number;
Expand All @@ -74,12 +77,15 @@ export class Transform {
*/
nearZ: number;

constructor(minZoom?: number, maxZoom?: number, minPitch?: number, maxPitch?: number, renderWorldCopies?: boolean) {
constructor(minZoom?: number, maxZoom?: number, minPitch?: number, maxPitch?: number, renderWorldCopies?: boolean, allowUnderzoom?: boolean, underzoom?: number, overpan?: number) {
this.tileSize = 512; // constant

this._renderWorldCopies = renderWorldCopies === undefined ? true : !!renderWorldCopies;
this._minZoom = minZoom || 0;
this._maxZoom = maxZoom || 22;
this._allowUnderzoom = allowUnderzoom === undefined ? false : !!allowUnderzoom;
this._underzoom = underzoom || 100;
this._overpan = overpan || 0;

this._minPitch = (minPitch === undefined || minPitch === null) ? 0 : minPitch;
this._maxPitch = (maxPitch === undefined || maxPitch === null) ? 60 : maxPitch;
Expand All @@ -103,7 +109,7 @@ export class Transform {
}

clone(): Transform {
const clone = new Transform(this._minZoom, this._maxZoom, this._minPitch, this.maxPitch, this._renderWorldCopies);
const clone = new Transform(this._minZoom, this._maxZoom, this._minPitch, this.maxPitch, this._renderWorldCopies, this._allowUnderzoom, this._underzoom, this._overpan);
clone.apply(this);
return clone;
}
Expand Down Expand Up @@ -163,6 +169,41 @@ export class Transform {
}

this._renderWorldCopies = renderWorldCopies;

this._constrain();
this._calcMatrices();
}

get allowUnderzoom(): boolean { return this._allowUnderzoom; }
set allowUnderzoom(allowUnderzoom: boolean) {
if (allowUnderzoom === undefined) {
allowUnderzoom = false;
} else if (allowUnderzoom === null) {
allowUnderzoom = false;
}

this._allowUnderzoom = allowUnderzoom;

this._constrain();
this._calcMatrices();
}

get underzoom(): number { return this._underzoom; }
set underzoom(underzoom: number) {
if (this._underzoom === underzoom) return;
this._underzoom = underzoom;

this._constrain();
this._calcMatrices();
}

get overpan(): number { return this._overpan; }
set overpan(overpan: number) {
if (this._overpan === overpan) return;
this._overpan = overpan;

this._constrain();
this._calcMatrices();
}

get worldSize(): number {
Expand Down Expand Up @@ -764,6 +805,7 @@ export class Transform {
* 1) everything beyond the bounds is excluded
* 2) a given lngLat is as near the center as possible
* Bounds are those set by maxBounds or North & South "Poles" and, if only 1 globe is displayed, antimeridian.
* Underzooming and overpanning beyond the bounds is done if 1 globe is displayed and allowUnderzoom=true.
*/
getConstrained(lngLat: LngLat, zoom: number): {center: LngLat; zoom: number} {
zoom = clamp(+zoom, this.minZoom, this.maxZoom);
Expand All @@ -788,12 +830,38 @@ export class Transform {
let scaleX = 0;
const {x: screenWidth, y: screenHeight} = this.size;

// For a single-world map, a user can underzoom the world to see it
// entirely in the viewport. This works by reducing the viewport's
// apparent size to a square with a side length equal to the smallest
// viewport dimension scaled by the `underzoom` percentage.
// The user can also overpan the world bounds up to 50% the viewport
// dimensions, limited by the `overpan` percentage.
// _________________________
// |viewport |
// | |
// | |
// | -———————————- |
// | |map bounds | |
// | | | |
// |·····|···········|<--->| overpan
// | | | |
// | -———————————- |
// |·····<----------->·····| underzoom
// | |
// | |
// |_______________________|

const underzoom = // 0-1 (percent as normalized factor of viewport minimum dimension)
(!this._renderWorldCopies && this._allowUnderzoom) ?
clamp(this._underzoom, 0, 100) / 100 :
1.0;

if (this.latRange) {
const latRange = this.latRange;
minY = mercatorYfromLat(latRange[1]) * worldSize;
maxY = mercatorYfromLat(latRange[0]) * worldSize;
const shouldZoomIn = maxY - minY < screenHeight;
if (shouldZoomIn) scaleY = screenHeight / (maxY - minY);
const shouldZoomIn = maxY - minY < (underzoom * screenHeight);
if (shouldZoomIn) scaleY = underzoom * screenHeight / (maxY - minY);
}

if (lngRange) {
Expand All @@ -810,27 +878,46 @@ export class Transform {

if (maxX < minX) maxX += worldSize;

const shouldZoomIn = maxX - minX < screenWidth;
if (shouldZoomIn) scaleX = screenWidth / (maxX - minX);
const shouldZoomIn = maxX - minX < (underzoom * screenWidth);
if (shouldZoomIn) scaleX = underzoom * screenWidth / (maxX - minX);
}

const {x: originalX, y: originalY} = this.project.call({worldSize}, lngLat);
let modifiedX, modifiedY;

const scale = Math.max(scaleX || 0, scaleY || 0);
const scale =
(!this._renderWorldCopies && this._allowUnderzoom) ?
Math.min(scaleX || 0, scaleY || 0) :
Math.max(scaleX || 0, scaleY || 0);

if (scale) {
// zoom in to exclude all beyond the given lng/lat ranges
const newPoint = new Point(
scaleX ? (maxX + minX) / 2 : originalX,
scaleY ? (maxY + minY) / 2 : originalY);
result.center = this.unproject.call({worldSize}, newPoint).wrap();
if (this._renderWorldCopies) result.center = this.unproject.call({worldSize}, newPoint).wrap();
result.zoom += this.scaleZoom(scale);
return result;
}

// Panning up and down in latitude is externally limited by project() with MAX_VALID_LATITUDE.
// This limit prevents panning the top and bottom bounds farther than the center of the viewport.
// Due to the complexity and consequence of altering project() or MAX_VALID_LATITUDE, we'll simply limit
// the overpan to 50% the bounds to match that external limit.
let lngOverpan = 0.0;
let latOverpan = 0.0;
if (!this._renderWorldCopies && this._allowUnderzoom) {
const overpan = 2 * clamp(this._overpan, 0, 50) / 100; // 0-1 (percent as a normalized factor from viewport edge to center)
const latUnderzoomMinimumPan = 1.0 - ((maxY - minY) / screenHeight);
const lngUnderzoomMinimumPan = 1.0 - ((maxX - minX) / screenWidth);
lngOverpan = Math.max(lngUnderzoomMinimumPan, overpan);
latOverpan = Math.max(latUnderzoomMinimumPan, overpan);
}
const lngPanScale = 1.0 - lngOverpan;
const latPanScale = 1.0 - latOverpan;

if (this.latRange) {
const h2 = screenHeight / 2;
const h2 = latPanScale * screenHeight / 2;
if (originalY - h2 < minY) modifiedY = minY + h2;
if (originalY + h2 > maxY) modifiedY = maxY - h2;
}
Expand All @@ -841,7 +928,7 @@ export class Transform {
if (this._renderWorldCopies) {
wrappedX = wrap(originalX, centerX - worldSize / 2, centerX + worldSize / 2);
}
const w2 = screenWidth / 2;
const w2 = lngPanScale * screenWidth / 2;

if (wrappedX - w2 < minX) modifiedX = minX + w2;
if (wrappedX + w2 > maxX) modifiedX = maxX - w2;
Expand All @@ -850,7 +937,8 @@ export class Transform {
// pan the map if the screen goes off the range
if (modifiedX !== undefined || modifiedY !== undefined) {
const newPoint = new Point(modifiedX ?? originalX, modifiedY ?? originalY);
result.center = this.unproject.call({worldSize}, newPoint).wrap();
result.center = this.unproject.call({worldSize}, newPoint);
if (this._renderWorldCopies) result.center = result.center.wrap();
}

return result;
Expand Down
152 changes: 151 additions & 1 deletion src/ui/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,21 @@ export type MapOptions = {
* @defaultValue true
*/
renderWorldCopies?: boolean;
/**
* If `true`, a user may zoom out a single-copy world beyond its bounds until the bounded area is a percent of the viewport size as defined by `underzoom` unless `minZoom` is reached. If `false`, a user may not zoom out beyond the map bounds.
* @defaultValue false
*/
allowUnderzoom?: boolean;
/**
* The allowable underzoom of the map, measured as a percentage of the size of the map's bounds relative to the viewport size (0-100). If `underzoom` is not specified in the constructor options, MapLibre GL JS will default to `100`. `allowUnderzoom` must be set `true` for underzooming to occur.
* @defaultValue 100
*/
underzoom?: number;
/**
* The allowable overpan of the map, measured as a percentage of how far the map's latitude-longitude bounds may be exceed relative to the viewport's height and width (0-100). If `overpan` is not specified in the constructor options, MapLibre GL JS will default to `0`. `allowUnderzoom` must be set `true` for overpanning to occur. `overpan` is exceeded when necessary for underzooming.
* @defaultValue 0
*/
overpan?: number;
/**
* The maximum number of tiles stored in the tile cache for a given source. If omitted, the cache will be dynamically sized based on the current viewport which can be set using `maxTileCacheZoomLevels` constructor options.
* @defaultValue null
Expand Down Expand Up @@ -359,6 +374,14 @@ const defaultMaxPitch = 60;
// use this variable to check maxPitch for validity
const maxPitchThreshold = 85;

const defaultUnderzoom = 80;
const minUnderzoom = 0;
const maxUnderzoom = 100;

const defaultOverpan = 0;
const minOverpan = 0;
const maxOverpan = 50;

const defaultOptions: Readonly<Partial<MapOptions>> = {
hash: false,
interactive: true,
Expand Down Expand Up @@ -392,6 +415,9 @@ const defaultOptions: Readonly<Partial<MapOptions>> = {
pitch: 0,

renderWorldCopies: true,
allowUnderzoom: false,
underzoom: defaultUnderzoom,
overpan: defaultOverpan,
maxTileCacheSize: null,
maxTileCacheZoomLevels: config.MAX_TILE_CACHE_ZOOM_LEVELS,
transformRequest: null,
Expand Down Expand Up @@ -580,7 +606,7 @@ export class Map extends Camera {
throw new Error(`maxPitch must be less than or equal to ${maxPitchThreshold}`);
}

const transform = new Transform(resolvedOptions.minZoom, resolvedOptions.maxZoom, resolvedOptions.minPitch, resolvedOptions.maxPitch, resolvedOptions.renderWorldCopies);
const transform = new Transform(resolvedOptions.minZoom, resolvedOptions.maxZoom, resolvedOptions.minPitch, resolvedOptions.maxPitch, resolvedOptions.renderWorldCopies, resolvedOptions.allowUnderzoom, resolvedOptions.underzoom, resolvedOptions.overpan);
super(transform, {bearingSnap: resolvedOptions.bearingSnap});

this._interactive = resolvedOptions.interactive;
Expand Down Expand Up @@ -1145,6 +1171,130 @@ export class Map extends Camera {
return this._update();
}

/**
* Returns the state of `allowUnderzoom`.
* If `true`, a user may zoom out a single-copy world beyond its bounds
* until the bounded area is a percent of the viewport size as defined by
* `underzoom` unless `minZoom` is reached.
* If `false`, a user may not zoom out beyond the map bounds.
*
* @returns The allowUnderzoom
* @example
* ```ts
* let worldUnderzoomAllowed = map.getAllowUnderzoom();
* ```
*/
getAllowUnderzoom(): boolean { return this.transform.allowUnderzoom; }

/**
* Sets the state of `allowUnderzoom`.
*
* @param allowUnderzoom - If `true`, a user may zoom out a single-copy
* world beyond its bounds until the bounded area is a percent of the
* viewport size as defined by `underzoom` unless `minZoom` is reached.
* If `false`, a user may not zoom out beyond the map bounds.
*
* `undefined` is treated as `true`, `null` is treated as `false`.
* @example
* ```ts
* map.setAllowUnderzoom(true);
* ```
*/
setAllowUnderzoom(allowUnderzoom?: boolean | null): Map {
this.transform.allowUnderzoom = allowUnderzoom;
return this._update();
}

/**
* Returns the map's allowable underzoom percentage.
*
* @returns underzoom
* @example
* ```ts
* let underzoom = map.getUnderzoom();
* ```
*/
getUnderzoom(): number { return this.transform.underzoom; }

/**
* Sets or clears the map's allowable underzoom percentage.
*
* `allowUnderzoom` must be `true` for underzooming to occur.
*
* If the map is currently zoomed out to a size lower than the new
* underzoom, the map will zoom in to respect the new underzoom.
*
* `minZoom` is always respected, meaning if the map is already at
* `minZoom = 0` but the map is larger than the allowable underzoom size,
* it is not possible to zoom out any further.
*
* A {@link ErrorEvent} event will be fired if underzoom is out of bounds.
*
* @param underzoom - The allowable underzoom percentage to set (0 - 100).
* If `null` or `undefined` is provided, the function removes the current
* underzoom and sets it to the default (80).
* @example
* ```ts
* map.setUnderzoom(25);
* ```
*/
setUnderzoom(underzoom?: number | null): Map {

underzoom = underzoom === null || underzoom === undefined ? defaultUnderzoom : underzoom;

if (underzoom > maxUnderzoom) {
throw new Error(`underzoom must be less than or equal to ${maxUnderzoom}`);
} else if (underzoom < minUnderzoom) {
throw new Error(`underzoom must be greater than or equal to ${minUnderzoom}`);
}

this.transform.underzoom = underzoom;
return this._update();
}

/**
* Returns the map's allowable overpan percentage.
*
* @returns overpan
* @example
* ```ts
* let overpan = map.getOverpan();
* ```
*/
getOverpan(): number { return this.transform.overpan; }

/**
* Sets or clears the map's allowable overpan percentage.
*
* `allowUnderzoom` must be `true` for overpanning to occur.
*
* If the map is underzoomed such that the bounds must exceed `overpan`, the
* map will allow that.
*
* A {@link ErrorEvent} event will be fired if overpan is out of bounds.
*
* @param overpan - The allowable overpan percentage to set (0 - 50).
* If `null` or `undefined` is provided, the function removes the current
* overpan and sets it to the default (0).
* @example
* ```ts
* map.setOverpan(25);
* ```
*/
setOverpan(overpan?: number | null): Map {

overpan = overpan === null || overpan === undefined ? defaultOverpan : overpan;

if (overpan > maxOverpan) {
throw new Error(`overpan must be less than or equal to ${maxOverpan}`);
} else if (overpan < minOverpan) {
throw new Error(`overpan must be greater than or equal to ${minOverpan}`);
}

this.transform.overpan = overpan;
return this._update();
}

/**
* Returns a [Point](https://github.com/mapbox/point-geometry) representing pixel coordinates, relative to the map's `container`,
* that correspond to the specified geographical location.
Expand Down
Loading
Loading