diff --git a/CHANGELOG.md b/CHANGELOG.md
index efc71f36..ba9f3eb9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,14 @@
# MapTiler SDK Changelog
## NEXT
+### New Features
+- New `MaptilerProjectionControl` to toggle globe/Mercator projection
+
### Bug Fixes
- Navigation now relies on `Map` methods instead of `Transform` methods for bearing due to globe projection being available
+
## 2.4.0
### New Features
- Shows a warning message in the map container if WebGL context is lost
diff --git a/biome.json b/biome.json
index 3ae7200e..68f18cb6 100644
--- a/biome.json
+++ b/biome.json
@@ -29,6 +29,9 @@
},
"linter": {
"rules": {
+ "style": {
+ "noInferrableTypes": "off"
+ },
"suspicious": {
"noExplicitAny": {
"level": "off"
diff --git a/images/screenshots/fov_1.jpeg b/images/screenshots/fov_1.jpeg
new file mode 100644
index 00000000..ef57137a
Binary files /dev/null and b/images/screenshots/fov_1.jpeg differ
diff --git a/images/screenshots/fov_10.jpeg b/images/screenshots/fov_10.jpeg
new file mode 100644
index 00000000..d506adf2
Binary files /dev/null and b/images/screenshots/fov_10.jpeg differ
diff --git a/images/screenshots/fov_20.jpeg b/images/screenshots/fov_20.jpeg
new file mode 100644
index 00000000..91841a0b
Binary files /dev/null and b/images/screenshots/fov_20.jpeg differ
diff --git a/images/screenshots/fov_30.jpeg b/images/screenshots/fov_30.jpeg
new file mode 100644
index 00000000..b95b9a63
Binary files /dev/null and b/images/screenshots/fov_30.jpeg differ
diff --git a/images/screenshots/fov_37.jpeg b/images/screenshots/fov_37.jpeg
new file mode 100644
index 00000000..35c0df4b
Binary files /dev/null and b/images/screenshots/fov_37.jpeg differ
diff --git a/images/screenshots/fov_40.jpeg b/images/screenshots/fov_40.jpeg
new file mode 100644
index 00000000..436f0e6a
Binary files /dev/null and b/images/screenshots/fov_40.jpeg differ
diff --git a/images/screenshots/fov_50.jpeg b/images/screenshots/fov_50.jpeg
new file mode 100644
index 00000000..6ad4ff7a
Binary files /dev/null and b/images/screenshots/fov_50.jpeg differ
diff --git a/images/screenshots/fov_60.jpeg b/images/screenshots/fov_60.jpeg
new file mode 100644
index 00000000..e9d97f93
Binary files /dev/null and b/images/screenshots/fov_60.jpeg differ
diff --git a/images/screenshots/globe_hybrid.jpeg b/images/screenshots/globe_hybrid.jpeg
new file mode 100644
index 00000000..8814e966
Binary files /dev/null and b/images/screenshots/globe_hybrid.jpeg differ
diff --git a/images/screenshots/globe_icon.png b/images/screenshots/globe_icon.png
new file mode 100644
index 00000000..6c84c548
Binary files /dev/null and b/images/screenshots/globe_icon.png differ
diff --git a/images/screenshots/globe_outdoor_alaska.jpeg b/images/screenshots/globe_outdoor_alaska.jpeg
new file mode 100644
index 00000000..389beb60
Binary files /dev/null and b/images/screenshots/globe_outdoor_alaska.jpeg differ
diff --git a/images/screenshots/globe_outdoor_emea.jpeg b/images/screenshots/globe_outdoor_emea.jpeg
new file mode 100644
index 00000000..dc3e8d03
Binary files /dev/null and b/images/screenshots/globe_outdoor_emea.jpeg differ
diff --git a/images/screenshots/globe_outdoor_panamerica.jpeg b/images/screenshots/globe_outdoor_panamerica.jpeg
new file mode 100644
index 00000000..3402d95d
Binary files /dev/null and b/images/screenshots/globe_outdoor_panamerica.jpeg differ
diff --git a/images/screenshots/globe_projection.jpeg b/images/screenshots/globe_projection.jpeg
new file mode 100644
index 00000000..2b033923
Binary files /dev/null and b/images/screenshots/globe_projection.jpeg differ
diff --git a/images/screenshots/globe_topo_arctica.jpeg b/images/screenshots/globe_topo_arctica.jpeg
new file mode 100644
index 00000000..93666548
Binary files /dev/null and b/images/screenshots/globe_topo_arctica.jpeg differ
diff --git a/images/screenshots/mercator_icon.png b/images/screenshots/mercator_icon.png
new file mode 100644
index 00000000..f61b7737
Binary files /dev/null and b/images/screenshots/mercator_icon.png differ
diff --git a/images/screenshots/mercator_projection.jpeg b/images/screenshots/mercator_projection.jpeg
new file mode 100644
index 00000000..648c5356
Binary files /dev/null and b/images/screenshots/mercator_projection.jpeg differ
diff --git a/package-lock.json b/package-lock.json
index 2cf1e6d2..ca715579 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,11 +9,11 @@
"version": "3.0.0",
"license": "BSD-3-Clause",
"dependencies": {
- "@maplibre/maplibre-gl-style-spec": "^20.3.1",
+ "@maplibre/maplibre-gl-style-spec": "^21.0.0",
"@maptiler/client": "^2.0.0",
"events": "^3.3.0",
"js-base64": "^3.7.4",
- "maplibre-gl": "^5.0.0-pre.3",
+ "maplibre-gl": "^5.0.0-pre.4",
"uuid": "^9.0.0"
},
"devDependencies": {
@@ -687,9 +687,9 @@
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
- "version": "20.4.0",
- "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz",
- "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==",
+ "version": "21.0.0",
+ "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-21.0.0.tgz",
+ "integrity": "sha512-uV762lmulFyBHqAD3eVqIYgwwi/p+cz+LkjmHgHfRWvNKMik+0vBaL6Bxio4/YdheKidv3+bm25kabOBf+/8EA==",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
@@ -2455,9 +2455,9 @@
}
},
"node_modules/maplibre-gl": {
- "version": "5.0.0-pre.3",
- "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.0.0-pre.3.tgz",
- "integrity": "sha512-wGRptDyS+d5aHucV4OXlNQ9y7fhOywXrSqLhPgvyzNzq88ah0dEA988ruJqnoeJfow6l6IHuqdwOTdvF1CPv9w==",
+ "version": "5.0.0-pre.4",
+ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.0.0-pre.4.tgz",
+ "integrity": "sha512-rYXOkIAFPM0LlhwQsLsL9972R63VogGeppO/zWJzukCx2n3Wu7TdvbEa2ethHb3Fa7mPcb+VhsgAQuV/WuGMeg==",
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -2466,7 +2466,7 @@
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^1.3.1",
"@mapbox/whoots-js": "^3.1.0",
- "@maplibre/maplibre-gl-style-spec": "^20.4.0",
+ "@maplibre/maplibre-gl-style-spec": "^21.0.0",
"@types/geojson": "^7946.0.14",
"@types/geojson-vt": "3.2.5",
"@types/mapbox__point-geometry": "^0.1.4",
diff --git a/package.json b/package.json
index 237157f8..c3e9e9cf 100644
--- a/package.json
+++ b/package.json
@@ -61,11 +61,11 @@
"vitest": "^0.34.2"
},
"dependencies": {
- "@maplibre/maplibre-gl-style-spec": "^20.3.1",
+ "@maplibre/maplibre-gl-style-spec": "^21.0.0",
"@maptiler/client": "^2.0.0",
"events": "^3.3.0",
"js-base64": "^3.7.4",
- "maplibre-gl": "^5.0.0-pre.3",
+ "maplibre-gl": "^5.0.0-pre.4",
"uuid": "^9.0.0"
}
}
diff --git a/readme.md b/readme.md
index 7fb48366..dfcaba33 100644
--- a/readme.md
+++ b/readme.md
@@ -261,6 +261,123 @@ And can even be provided in the URI form:
map.setStyle("maptiler://c912ffc8-2360-487a-973b-59d037fb15b8");
```
+# Globe or Mercator projection?
+The **Web Mercator projection** [*(Wikipedia)*](https://en.wikipedia.org/wiki/Web_Mercator_projection) has been the go-to standard in cartography since the early days or web mapping. Partly for technical reasons but also because it is great for navigation as well as for showing the entire world in one screen, with no hidden face. That being said, Mercator's heavy distorsion at high latitudes, as well a the discontinuity at the poles can be a limitation for data visualization and has been critisized for providing a biased view of the world.
+
+The **globe projection**, available starting from MapTiler SDK v3, does not suffer from these biases and can feel overall more playfull than Mercator. It can be a great choice for semi-global data visualization, especially for data close to the poles, thanks to its geographic continuity.
+
+
+| Mercator projection | Globe projection |
+| :--------: | :-------: |
+| ![](images/screenshots/mercator_projection.jpeg) | ![](images/screenshots/globe_projection.jpeg) |
+
+The choice between Mercator and Globe can be done at different levels and moments in the lifecycle of the map, yet, unless stated otherwise, **Mercator remains the default**.
+
+- In the style, using the `projection` top-level property.
+For globe:
+```js
+{
+ "version": ...,
+ "id": ...,
+ "name": ...,
+ "sources": ...,
+ "layers": ...,
+
+ // Make the style use the globe projection at load time
+ "projection": {
+ "type": "globe"
+ }
+
+ // ...
+}
+```
+or for Mercator:
+```js
+{
+ "version": ...,
+ "id": ...,
+ "name": ...,
+ "sources": ...,
+ "layers": ...,
+
+ // Make the style use the mercator projection at load time
+ "projection": {
+ "type": "mercator"
+ }
+
+ // ...
+}
+```
+
+- In the constructor of the `Map` class, using the `projection` option. For globe:
+```ts
+const map = new maptilersdk.Map({
+ container: "map",
+ projection: "globe", // Force a globe projection
+});
+```
+or for Mercator:
+```ts
+const map = new maptilersdk.Map({
+ container: "map",
+ projection: "mercator", // Force a mercator projection
+})
+```
+This will overwrite the `projection` property from the style (if any) and will persist it later if the map style was to change.
+
+- Use the built-in methods:
+```ts
+const animate = false;
+map.enableGlobeProjection(animate);
+// or
+map.enableMercatorProjection(animate);
+```
+
+The animated transition is enabled by default, but can be disabled by passing `false`, as in the example above. Similarly to the `projection` option in the constructor, this will overwrite the projection settings from style (if any) and persist it when the style is updated.
+
+The projection setter built in Maplibre GL JS is also usable:
+```ts
+map.setProjection({type: "mercator"});
+// or
+map.setProjection({type: "globe"});
+```
+but this will not automatically animate the transition and may cause rendering glitches.
+
+- Using the `MaptilerProjectionControl`. Not mounted by default, it can easily be added with a single option in the `Map` constructor:
+```ts
+const map = new maptilersdk.Map({
+ container: "map",
+ projectionControl: true, // or the position such as "top-left", "top-right",
+}); // "bottom-right" or "bottom-left"
+```
+This dedicated control will show a globe icon to transition from Mercator to globe projection and will show a flat map icon to transition from globe to Mercator projection. The chosen projection persist with future style changes.
+
+## Field of view (FOV)
+The internal camera has a default vertical field of view [*(wikipedia)*](https://en.wikipedia.org/wiki/Field_of_view) of a wide ~36.86 degrees. In globe mode, such a large *FOV* reduces the amount of the Earth that can be seen at once and exaggerates the central part, comparably to a fisheye lens. In many cases, a narrower *FOV* is preferable. Here is how to update if:
+
+```ts
+// Ajust de FOV, with values from 1 to 50
+map.transform.setFov(10);
+map.redraw();
+```
+> 📣 *__Note:__* with the Mercator projection, it is possible to set a FOV of `0`, which yields a true orthographic projection [*(wikipedia)*](https://en.wikipedia.org/wiki/Orthographic_projection), but the globe projection does not allow this.
+
+Here is a table of FOV comparison:
+| 01° | 10° | 20° | 30° | 40° | 50° |
+| :--------: | :-------: |:-------: |:-------: |:-------: |:-------: |
+| ![](images/screenshots/fov_1.jpeg) | ![](images/screenshots/fov_10.jpeg) | ![](images/screenshots/fov_20.jpeg) | ![](images/screenshots/fov_30.jpeg) | ![](images/screenshots/fov_40.jpeg) | ![](images/screenshots/fov_50.jpeg) |
+
+
+## Globe screenshots
+![](images/screenshots/globe_topo_arctica.jpeg)
+![](images/screenshots/globe_hybrid.jpeg)
+![](images/screenshots/globe_outdoor_alaska.jpeg)
+![](images/screenshots/globe_outdoor_emea.jpeg)
+![](images/screenshots/globe_outdoor_panamerica.jpeg)
+
+
+> 📣 *__Note:__* Terrain is not fully compatible with the globe projection yet so it's better to disable it at low zoom level (from afar) and to choose the Mercator projection at higher zoom level (from up close).
+
# Centering the map on visitors
It is sometimes handy to center the map on the visitor's location, and there are multiple ways of doing it but for the SDK, we have decided to make this extra simple by using the [IP geolocation](#%EF%B8%8F%EF%B8%8F-geolocation) API provided by [MapTiler Cloud](https://docs.maptiler.com/cloud/api/geolocation/), directly exposed as a single option of the `Map` constructor. There are two strategies:
1. `POINT`: centering the map on the actual visitor location, optionally using the `zoom` option (zoom level `13` if none is provided). As a more precise option, if the user has previously granted access to the browser location (more precise) then this is going to be used.
diff --git a/src/Map.ts b/src/Map.ts
index 0ff0522b..2f4b4d0e 100644
--- a/src/Map.ts
+++ b/src/Map.ts
@@ -37,6 +37,7 @@ import { FullscreenControl } from "./MLAdapters/FullscreenControl";
import Minimap from "./Minimap";
import type { MinimapOptionsInput } from "./Minimap";
import { CACHE_API_AVAILABLE, registerLocalCacheProtocol } from "./caching";
+import { MaptilerProjectionControl } from "./MaptilerProjectionControl";
export type LoadWithTerrainEvent = {
type: "loadWithTerrain";
@@ -62,6 +63,12 @@ type MapTerrainDataEvent = MapDataEvent & {
source: RasterDEMSourceSpecification;
};
+/**
+ * The type of projection, `undefined` means it's decided by the style and if the style does not contain any projection info,
+ * if falls back to the default Mercator
+ */
+export type ProjectionTypes = "mercator" | "globe" | undefined;
+
/**
* Options to provide to the `Map` constructor
*/
@@ -170,6 +177,17 @@ export type MapOptions = Omit & {
* Default: `false`
*/
geolocate?: (typeof GeolocationType)[keyof typeof GeolocationType] | boolean;
+
+ /**
+ * Show the projection control. (default: `false`, will show if `true`)
+ */
+ projectionControl?: boolean | ControlPosition;
+
+ /**
+ * Whether the projection should be "mercator" or "globe".
+ * If not provided, the style takes precedence. If provided, overwrite the style.
+ */
+ projection?: ProjectionTypes;
};
/**
@@ -189,6 +207,7 @@ export class Map extends maplibregl.Map {
private terrainAnimationDuration = 1000;
private monitoredStyleUrls!: Set;
private styleInProcess = false;
+ private curentProjection: ProjectionTypes = undefined;
constructor(options: MapOptions) {
displayNoWebGlWarning(options.container);
@@ -306,6 +325,19 @@ export class Map extends maplibregl.Map {
this.languageAlwaysBeenStyle = this.primaryLanguage === Language.STYLE;
this.terrainExaggeration = options.terrainExaggeration ?? this.terrainExaggeration;
+ this.curentProjection = options.projection;
+
+ // Managing the type of projection and persist if not present in style
+ this.on("styledata", () => {
+ if (this.curentProjection === "mercator") {
+ this.setProjection({ type: "mercator" });
+ } else if (this.curentProjection === "globe") {
+ this.setProjection({ type: "globe" });
+ // @ts-ignore
+ this.transform.setGlobeViewAllowed(true, true); // the first `true` means globe
+ }
+ });
+
// Map centering and geolocation
this.once("styledata", async () => {
// Not using geolocation centering if...
@@ -508,6 +540,16 @@ export class Map extends maplibregl.Map {
this.addControl(new MaptilerTerrainControl(), position);
}
+ if (options.projectionControl) {
+ // default position, if not provided, is top left corner
+ const position = (
+ options.projectionControl === true || options.projectionControl === undefined
+ ? "top-right"
+ : options.projectionControl
+ ) as ControlPosition;
+ this.addControl(new MaptilerProjectionControl(), position);
+ }
+
// By default, no fullscreen control
if (options.fullscreenControl) {
// default position, if not provided, is top left corner
@@ -1430,4 +1472,60 @@ export class Map extends maplibregl.Map {
super.setTransformRequest(combineTransformRequest(transformRequest));
return this;
}
+
+ /**
+ * Returns whether a globe projection is currently being used
+ */
+ isGlobeprojection(): boolean {
+ const projection = this.getProjection();
+ if (!projection) return false;
+ // @ts-ignore
+ return projection.type === "globe" && this.transform.getGlobeViewAllowed();
+ }
+
+ /**
+ * Uses the globe projection. Animated by default, it can be disabled
+ */
+ enableGlobeProjection(animate: boolean = true) {
+ if (this.isGlobeprojection()) return;
+
+ // From Mercator to Globe
+ this.setProjection({ type: "globe" });
+
+ if (animate) {
+ // @ts-ignore
+ this.transform.setGlobeViewAllowed(false, true); // the `false` means mercator
+
+ this.once("projectiontransition", () => {
+ // @ts-ignore
+ this.transform.setGlobeViewAllowed(true, true);
+ });
+ } else {
+ // @ts-ignore
+ this.transform.setGlobeViewAllowed(true, true);
+ }
+
+ this.curentProjection = "globe";
+ }
+
+ /**
+ * Uses the Mercator projection. Animated by default, it can be disabled
+ */
+ enableMercatorProjection(animate: boolean = true) {
+ if (!this.isGlobeprojection()) return;
+
+ if (animate) {
+ // From Globe to Mercator
+ this.setProjection({ type: "globe" });
+ // @ts-ignore
+ this.transform.setGlobeViewAllowed(false, true);
+ this.once("projectiontransition", () => {
+ this.setProjection({ type: "mercator" });
+ });
+ } else {
+ this.setProjection({ type: "mercator" });
+ }
+
+ this.curentProjection = "mercator";
+ }
}
diff --git a/src/MaptilerProjectionControl.ts b/src/MaptilerProjectionControl.ts
new file mode 100644
index 00000000..de73d229
--- /dev/null
+++ b/src/MaptilerProjectionControl.ts
@@ -0,0 +1,57 @@
+import { DOMcreate, DOMremove } from "./tools";
+import type { Map as SDKMap } from "./Map";
+import type { IControl } from "maplibre-gl";
+
+/**
+ * A `MaptilerProjectionControl` control adds a button to switch from Mercator to Globe projection.
+ */
+export class MaptilerProjectionControl implements IControl {
+ map!: SDKMap;
+ container!: HTMLElement;
+ projectionButton!: HTMLButtonElement;
+
+ onAdd(map: SDKMap): HTMLElement {
+ this.map = map;
+ this.container = DOMcreate("div", "maplibregl-ctrl maplibregl-ctrl-group");
+ this.projectionButton = DOMcreate("button", "maplibregl-ctrl-projection", this.container);
+ DOMcreate("span", "maplibregl-ctrl-icon", this.projectionButton).setAttribute("aria-hidden", "true");
+ this.projectionButton.type = "button";
+ this.projectionButton.addEventListener("click", this.toggleProjection.bind(this));
+
+ map.on("projectiontransition", this.updateProjectionIcon.bind(this));
+
+ this.updateProjectionIcon();
+ return this.container;
+ }
+
+ onRemove(): void {
+ DOMremove(this.container);
+ this.map.off("projectiontransition", this.updateProjectionIcon);
+ // @ts-expect-error: map will only be undefined on remove
+ this.map = undefined;
+ }
+
+ private toggleProjection(): void {
+ if (this.map.getProjection() === undefined) {
+ this.map.setProjection({ type: "mercator" });
+ }
+ if (this.map.isGlobeprojection()) {
+ this.map.enableMercatorProjection();
+ } else {
+ this.map.enableGlobeProjection();
+ }
+ this.updateProjectionIcon();
+ }
+
+ private updateProjectionIcon(): void {
+ this.projectionButton.classList.remove("maplibregl-ctrl-projection-globe");
+ this.projectionButton.classList.remove("maplibregl-ctrl-projection-mercator");
+ if (this.map.isGlobeprojection()) {
+ this.projectionButton.classList.add("maplibregl-ctrl-projection-mercator");
+ this.projectionButton.title = "Enable Mercator projection";
+ } else {
+ this.projectionButton.classList.add("maplibregl-ctrl-projection-globe");
+ this.projectionButton.title = "Enable Globe projection";
+ }
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index 2c8c0aa9..3247a39a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -192,6 +192,7 @@ export * from "./MaptilerGeolocateControl";
export * from "./MaptilerLogoControl";
export * from "./MaptilerTerrainControl";
export * from "./MaptilerNavigationControl";
+export * from "./MaptilerProjectionControl";
export {
type AutomaticStaticMapOptions,
type BoundedStaticMapOptions,
diff --git a/src/style/style_template.css b/src/style/style_template.css
index 600b79b5..62110b6a 100644
--- a/src/style/style_template.css
+++ b/src/style/style_template.css
@@ -133,6 +133,16 @@
background-image: url([src/style/svg/v6-fullscreen-off.svg]);
}
+/* Projection control icon - globe */
+.maplibregl-ctrl button.maplibregl-ctrl-projection-globe .maplibregl-ctrl-icon {
+ background-image: url([src/style/svg/v6-globe-projection.svg]);
+}
+
+/* Projection control icon - mercator */
+.maplibregl-ctrl button.maplibregl-ctrl-projection-mercator .maplibregl-ctrl-icon {
+ background-image: url([src/style/svg/v6-mercator-projection.svg]);
+}
+
.maplibregl-ctrl-scale {
background-color: hsla(0,0%,100%,.75);
border: 1px solid #444952;
diff --git a/src/style/svg/v6-globe-projection.svg b/src/style/svg/v6-globe-projection.svg
new file mode 100644
index 00000000..a0b96762
--- /dev/null
+++ b/src/style/svg/v6-globe-projection.svg
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/style/svg/v6-mercator-projection.svg b/src/style/svg/v6-mercator-projection.svg
new file mode 100644
index 00000000..b2c89bc2
--- /dev/null
+++ b/src/style/svg/v6-mercator-projection.svg
@@ -0,0 +1,7 @@
+
+
+