diff --git a/.storybook/preview.js b/.storybook/preview.js
index 02cd78ac8..e4a35d6f1 100644
--- a/.storybook/preview.js
+++ b/.storybook/preview.js
@@ -12,6 +12,7 @@ import "@eox/layercontrol";
import "@eox/layout";
import "@eox/map";
import "@eox/map/src/plugins/advancedLayersAndSources";
+import "@eox/map/src/plugins/globe";
import "@eox/stacinfo";
import "@eox/storytelling";
import "@eox/timecontrol";
diff --git a/elements/map/package.json b/elements/map/package.json
index b065837ee..1a2862434 100644
--- a/elements/map/package.json
+++ b/elements/map/package.json
@@ -9,6 +9,7 @@
"dependencies": {
"@eox/elements-utils": "^1.1.0",
"@eox/ui": "^0.3.6",
+ "@openglobus/og": "^0.27.19",
"flatgeobuf": "^4.0.1",
"lit": "^3.2.0",
"ol": "^10.6.1",
diff --git a/elements/map/src/custom/sources/WMTSCapabilities.js b/elements/map/src/custom/sources/WMTSCapabilities.js
index 883d09ba0..2b4b932f9 100644
--- a/elements/map/src/custom/sources/WMTSCapabilities.js
+++ b/elements/map/src/custom/sources/WMTSCapabilities.js
@@ -81,7 +81,9 @@ class WMTSCapabilities extends TileImage {
this.urls.map(this.createFromWMTSTemplate.bind(this)),
);
}
- this.setState("ready");
+ if (this.getState() !== "ready") {
+ this.setState("ready");
+ }
}
/**
diff --git a/elements/map/src/main.js b/elements/map/src/main.js
index cc2644866..b4b9ced08 100644
--- a/elements/map/src/main.js
+++ b/elements/map/src/main.js
@@ -550,7 +550,7 @@ export class EOxMap extends LitElement {
${eoxStyle}
${controlCss}
-
+
`;
}
diff --git a/elements/map/src/methods/map/first-updated.js b/elements/map/src/methods/map/first-updated.js
index a12274be7..e01c4e55b 100644
--- a/elements/map/src/methods/map/first-updated.js
+++ b/elements/map/src/methods/map/first-updated.js
@@ -8,13 +8,21 @@ import { animateToStateMethod } from "./";
* @param {import("../../main").EOxMap} EOxMap - The map object containing the map instance, center, animation options, and other properties.
*/
export default function firstUpdatedMethod(zoomExtent, EOxMap) {
+ if (EOxMap.projection === "globe") {
+ const globeDiv = document.createElement("div");
+ globeDiv.id = "globe";
+ globeDiv.style.width = "100%";
+ globeDiv.style.height = "100%";
+ EOxMap.renderRoot.appendChild(globeDiv);
+ window.eoxMapGlobe.create({ EOxMap, target: globeDiv });
+ }
// Set the center of the map once the target changes
EOxMap.map.once("change:target", (e) => {
e.target.getView().setCenter(EOxMap.center);
});
// Set the target element for the map rendering
- EOxMap.map.setTarget(EOxMap.renderRoot.querySelector("div"));
+ EOxMap.map.setTarget(EOxMap.renderRoot.querySelector("div#map"));
// Fit the map view to the specified extent if provided; otherwise, animate to the default state
if (zoomExtent) EOxMap.map.getView().fit(zoomExtent, EOxMap.animationOptions);
diff --git a/elements/map/src/methods/map/setters.js b/elements/map/src/methods/map/setters.js
index 946f78199..f9cd7faf9 100644
--- a/elements/map/src/methods/map/setters.js
+++ b/elements/map/src/methods/map/setters.js
@@ -334,6 +334,72 @@ export function setConfigMethod(config, EOxMap) {
* @returns {ProjectionLike} - The new projection code.
*/
export function setProjectionMethod(projection, oldProjection, EOxMap) {
+ if (projection === "globe") {
+ if (EOxMap.shadowRoot) {
+ let globeDiv = EOxMap.shadowRoot.querySelector("#globe");
+ if (!globeDiv) {
+ globeDiv = document.createElement("div");
+ globeDiv.id = "globe";
+ globeDiv.style.width = "100%";
+ globeDiv.style.height = "100%";
+ EOxMap.renderRoot?.appendChild(globeDiv);
+ }
+ window.eoxMapGlobe.create({ EOxMap, target: globeDiv });
+ EOxMap.shadowRoot.querySelector("#map").style.display = "none";
+ }
+
+ return projection;
+ }
+ if (oldProjection === "globe") {
+ const globe = EOxMap.globe;
+ const planet = globe.planet;
+
+ // 1. Determine the target point on the terrain at the center of the viewport
+ let c = planet.getCartesianFromPixelTerrain(
+ globe.renderer.handler.getCenter(),
+ );
+
+ // Fallback: If no terrain is found at the center, just use the current camera eye position
+ const targetCartesian = c
+ ? c.normal().scaleTo(c.length() + c.distance(planet.camera.eye))
+ : planet.camera.eye;
+
+ planet.flyCartesian(targetCartesian, {
+ amplitude: 0,
+ // The critical part: all final calculations and view settings
+ // MUST happen inside the completeCallback.
+ completeCallback: () => {
+ // If a target (c) was found, make the camera look at it (straight down)
+ if (c) {
+ planet.camera.look(c);
+ }
+
+ // Recalculate the camera position AFTER the flight and 'look' adjustments are complete
+ const finalCameraPosition = globe.planet.camera.getLonLat();
+
+ // Calculate the OpenLayers zoom level using the camera's final height
+ // The factor (27050000) is a known conversion constant for this library setup.
+ const zoomFromGlobe =
+ Math.log2(27050000 / finalCameraPosition.height) + 1;
+
+ // Calculate the OpenLayers center coordinates
+ const centerFromGlobe = [
+ finalCameraPosition.lon,
+ finalCameraPosition.lat,
+ ];
+ const newCenter = olTransform(centerFromGlobe, "EPSG:4326", projection);
+
+ // Apply the calculated center and zoom to the OpenLayers map view
+ EOxMap.map.getView().setCenter(newCenter);
+ EOxMap.map.getView().setZoom(zoomFromGlobe);
+
+ // Finally, switch the display from 3D to 2D
+ EOxMap.shadowRoot.querySelector("#globe").style.display = "none";
+ EOxMap.shadowRoot.querySelector("#map").style.display = "";
+ },
+ });
+ oldProjection = "EPSG:4326"; // reset oldProjection to a WGS84 projection
+ }
let newProj = oldProjection;
const oldView = EOxMap.map.getView();
diff --git a/elements/map/src/plugins/globe/index.js b/elements/map/src/plugins/globe/index.js
new file mode 100644
index 000000000..f75005ade
--- /dev/null
+++ b/elements/map/src/plugins/globe/index.js
@@ -0,0 +1,47 @@
+import { createGlobe } from "./openglobus.js";
+import { createMapPool, distributeTileToIdealMap } from "./methods.js";
+
+const create = ({ EOxMap, target }) => {
+ /**
+ * The maximum amount of maps to be spawned for rendering
+ */
+ const maxMaps = navigator.hardwareConcurrency || 4;
+
+ /**
+ * Create map pool of eox-maps for rendering in parallel
+ */
+ const mapPool = createMapPool(maxMaps, EOxMap, target);
+
+ /**
+ * Create the globe and render tiles, calling a callback
+ * for each once done
+ */
+ const globe = createGlobe({
+ map: EOxMap,
+ target,
+ renderTile: (tile, callback) => {
+ /**
+ * For each tile, distribute to ideal map
+ */
+ const mapPoolCandidate = distributeTileToIdealMap(
+ mapPool,
+ tile,
+ (renderedMapCanvas) => {
+ callback(renderedMapCanvas);
+ },
+ );
+
+ /**
+ * Start loading immediately if the map was idle
+ */
+ if (mapPoolCandidate.tileQueue.length === 1) {
+ mapPoolCandidate.loadNextTile();
+ }
+ },
+ });
+ EOxMap.globe = globe;
+};
+
+window.eoxMapGlobe = {
+ create,
+};
diff --git a/elements/map/src/plugins/globe/methods.js b/elements/map/src/plugins/globe/methods.js
new file mode 100644
index 000000000..48018e22c
--- /dev/null
+++ b/elements/map/src/plugins/globe/methods.js
@@ -0,0 +1,177 @@
+import { createXYZ } from "ol/tilegrid";
+
+const tileGrid = createXYZ({
+ extent: [-180, -90, 180, 90],
+ tileSize: 300,
+ maxResolution: 0.6,
+ maxZoom: 22,
+});
+
+/**
+ * Creates a map pool with a maximum number of maps,
+ * each map accepting a tile queue to render
+ *
+ * @param {Number} maxMaps - The maximum number of parallel rendering maps to be spawned
+ * @param {import("../../main").EOxMap} EOxMap - The map element containing the map instance, center, animation options, and other properties.
+ * @param {HTMLElement} target - The target to attach the rendering maps to
+ * @returns A map pool ready to accept jobs
+ */
+export const createMapPool = (maxMaps, EOxMap, target) =>
+ Array.from({ length: maxMaps }, () => {
+ EOxMap.addEventListener("mapmounted", () => {
+ EOxMap.map.getTargetElement().style.display = "none";
+ });
+ const tileMap =
+ /** @type {import("../../main").EOxMap} **/ document.createElement(
+ "eox-map",
+ );
+ Object.assign(tileMap.style, {
+ width: `256px`,
+ height: `256px`,
+ position: "absolute",
+ top: `-9999px`,
+ left: `-9999px`,
+ });
+ Object.assign(tileMap, {
+ projection: "EPSG:4326",
+ layers: EOxMap.layers.reverse(),
+ });
+ target.appendChild(tileMap);
+
+ return {
+ tileMap,
+ tileQueue: [],
+ loadNextTile() {
+ requestTileFromMap(tileMap, this.tileQueue[0], this);
+ },
+ };
+ });
+
+/**
+ * Request a single tile from a render map
+ *
+ * @param {import("../../main").EOxMap} tileMap - The map element currently rendering the tile
+ * @param {{
+ * tile: {
+ * x: import("ol/coordinate").Coordinate,
+ * y: import("ol/coordinate").Coordinate,
+ * z: import("ol/coordinate").Coordinate,
+ * callback: Function
+ * }}} job - The job for one single tile to be rendered
+ * @param {*} mapPoolMap - The map pool object
+ */
+async function requestTileFromMap(tileMap, job, mapPoolMap) {
+ const extent = tileGrid.getTileCoordExtent([
+ job.tile.z,
+ job.tile.x,
+ job.tile.y,
+ ]);
+ const map = tileMap.map;
+ map.getView().fit(extent, {
+ callback: () => {
+ map.once("rendercomplete", function () {
+ // debugger
+ const mapCanvas = document.createElement("canvas");
+ const size = map.getSize();
+ mapCanvas.width = size[0];
+ mapCanvas.height = size[1];
+ const mapContext = mapCanvas.getContext("2d");
+ Array.prototype.forEach.call(
+ map
+ .getViewport()
+ .querySelectorAll(".ol-layer canvas, canvas.ol-layer"),
+ function (canvas) {
+ if (canvas.width > 0) {
+ const opacity =
+ canvas.parentNode.style.opacity || canvas.style.opacity;
+ mapContext.globalAlpha = opacity === "" ? 1 : Number(opacity);
+ let matrix;
+ const transform = canvas.style.transform;
+ if (transform) {
+ // Get the transform parameters from the style's transform matrix
+ matrix = transform
+ .match(/^matrix\(([^\(]*)\)$/)[1]
+ .split(",")
+ .map(Number);
+ } else {
+ matrix = [
+ parseFloat(canvas.style.width) / canvas.width,
+ 0,
+ 0,
+ parseFloat(canvas.style.height) / canvas.height,
+ 0,
+ 0,
+ ];
+ }
+ // Apply the transform to the export map context
+ CanvasRenderingContext2D.prototype.setTransform.apply(
+ mapContext,
+ matrix,
+ );
+ const backgroundColor = canvas.parentNode.style.backgroundColor;
+ if (backgroundColor) {
+ mapContext.fillStyle = backgroundColor;
+ mapContext.fillRect(0, 0, canvas.width, canvas.height);
+ }
+ mapContext.drawImage(canvas, 0, 0);
+ }
+ },
+ );
+ mapContext.globalAlpha = 1;
+ mapContext.setTransform(1, 0, 0, 1, 0, 0);
+ job.callback(mapCanvas);
+ mapPoolMap.tileQueue.shift();
+ if (mapPoolMap.tileQueue.length) {
+ // load next tile in queuedTile
+ mapPoolMap.loadNextTile();
+ }
+ });
+ map.renderSync();
+ },
+ });
+}
+
+/**
+ * Distributor function to get an ideal worker from the pool
+ * based on distance of queued tiles
+ *
+ * @param {*} mapPool - TODO
+ * @param {*} tile - TODO
+ * @param {*} callback - TODO
+ */
+export function distributeTileToIdealMap(mapPool, tile, callback) {
+ const { x, y, z } = tile;
+ const job = {
+ tile,
+ callback,
+ };
+ let minDistance = 0;
+ let idealMapObj = null;
+
+ for (let i = 0; i < mapPool.length; i++) {
+ const mapObj = mapPool[i];
+ if (!mapObj.tileQueue.length) {
+ // if the worker is idle, use it
+ idealMapObj = mapObj;
+ break;
+ }
+
+ for (let j = 0; j < mapObj.tileQueue.length; j++) {
+ const queuedJob = mapObj.tileQueue[j];
+ const distance =
+ Math.abs(queuedJob.tile.x - x) +
+ Math.abs(queuedJob.tile.y - y) +
+ Math.abs(queuedJob.tile.z - z);
+ if (minDistance === 0 || distance < minDistance) {
+ minDistance = distance;
+ idealMapObj = mapObj;
+ if (i === mapPool.length - 1) {
+ // if we are at the last worker, no need to look further
+ break;
+ }
+ }
+ }
+ }
+ idealMapObj.tileQueue.push(job);
+ return idealMapObj;
+}
diff --git a/elements/map/src/plugins/globe/openglobus.js b/elements/map/src/plugins/globe/openglobus.js
new file mode 100644
index 000000000..7d7693520
--- /dev/null
+++ b/elements/map/src/plugins/globe/openglobus.js
@@ -0,0 +1,80 @@
+import {
+ Globe,
+ CanvasTiles,
+ quadTreeStrategyType,
+ Vec3,
+ LonLat,
+} from "@openglobus/og";
+
+import { transform as olTransform } from "ol/proj";
+
+export const createGlobe = ({ map, target, renderTile }) => {
+ const height = 27050000 / Math.pow(2, map.map.getView().getZoom() - 1);
+ const mapExtent = map.map.getView().calculateExtent();
+ const center = map.map.getView().getCenter();
+ const newCenter = olTransform(
+ center,
+ map.map.getView().getProjection().getCode(),
+ "EPSG:4326",
+ );
+ let globus = map.globe;
+ if (globus) {
+ const globeDiv = map.renderRoot.querySelector("#globe");
+ globeDiv.style.display = "";
+ // set up the zoom level
+ globus.planet.camera.setLonLat(
+ new LonLat(newCenter[0], newCenter[1], height),
+ new LonLat(newCenter[0], newCenter[1], 0),
+ Vec3.NORTH,
+ );
+ } else {
+ const canvasTilesLayer = new CanvasTiles("mainThreadCanvasTilesLayer", {
+ visibility: true,
+ isBaseLayer: false,
+ async drawTile(material, applyCanvas) {
+ if (!material.segment) {
+ return;
+ }
+ const tile = {
+ x: material.segment.tileX,
+ y: material.segment.tileY,
+ z: material.segment.tileZoom,
+ };
+
+ renderTile(tile, (renderedMapCanvas) => {
+ applyCanvas(renderedMapCanvas);
+ });
+ },
+ });
+ globus = new Globe({
+ target,
+ layers: [canvasTilesLayer],
+ quadTreeStrategyPrototype: quadTreeStrategyType.epsg4326,
+ sun: { active: false },
+ atmosphereEnabled: false,
+ viewExtent: mapExtent,
+ });
+
+ const style = document.createElement("style");
+ style.textContent = `
+ .og-inner {
+ height: 100%;
+ }
+ .og-inner *:not(canvas) {
+ display: none !important;
+ }
+ `;
+ target.appendChild(style);
+ globus.planet.renderer.controls.SimpleSkyBackground.colorOne =
+ "rgba(255,255,255)";
+ globus.planet.renderer.controls.SimpleSkyBackground.colorTwo =
+ "rgba(255,255,255)";
+ // set up the zoom level
+ globus.planet.camera.setLonLat(
+ new LonLat(newCenter[0], newCenter[1], height),
+ new LonLat(newCenter[0], newCenter[1], 0),
+ Vec3.NORTH,
+ );
+ }
+ return globus;
+};
diff --git a/elements/map/stories/globe.js b/elements/map/stories/globe.js
new file mode 100644
index 000000000..cde48901c
--- /dev/null
+++ b/elements/map/stories/globe.js
@@ -0,0 +1,91 @@
+/**
+ * Basic Globe rendered using `projection: "globe"`
+ *
+ * @returns {Object} The story configuration with arguments for the component.
+ */
+const GlobeStory = {
+ args: {
+ center: [15, 48],
+ projection: "globe",
+ layers: [
+ // {
+ // type: "Tile",
+ // properties: {
+ // id: "EOX",
+ // },
+ // source: {
+ // type: "WMTSCapabilities",
+ // url: "https://tiles.maps.eox.at/wmts/1.0.0/WMTSCapabilities.xml",
+ // layer: "s2cloudless-2024",
+ // crossOrigin: 'anonymous'
+ // },
+ // },
+ {
+ type: "MapboxStyle",
+ properties: {
+ id: "mapboxStyleGroup",
+ title: "mapboxStyleGroup",
+ mapboxStyle: {
+ version: 8,
+ sources: {
+ osm: {
+ type: "raster",
+ tiles: ["https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"],
+ tileSize: 256,
+ attribution: "© OpenStreetMap contributors",
+ },
+ pointSource: {
+ type: "geojson",
+ data: {
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [0, 0],
+ },
+ properties: {},
+ },
+ ],
+ },
+ },
+ },
+ layers: [
+ {
+ id: "osm-base",
+ type: "raster",
+ source: "osm",
+ // minzoom: 0,
+ // maxzoom: 19,
+ },
+ {
+ id: "point-halo",
+ type: "circle",
+ source: "pointSource",
+ paint: {
+ "circle-radius": 10,
+ "circle-color": "#0000ff",
+ "circle-opacity": 0.5,
+ },
+ },
+ {
+ id: "point-main",
+ type: "circle",
+ source: "pointSource",
+ paint: {
+ "circle-radius": 5,
+ "circle-color": "#ff0000",
+ },
+ },
+ ],
+ },
+ },
+ },
+ // { type: "Tile", properties: { id: "osm" }, source: { type: "OSM" } },
+ ],
+ zoom: 7,
+ },
+};
+
+export default GlobeStory;
diff --git a/elements/map/stories/index.js b/elements/map/stories/index.js
index ec15f2448..111025ceb 100644
--- a/elements/map/stories/index.js
+++ b/elements/map/stories/index.js
@@ -23,6 +23,7 @@ export { default as MapSyncStory } from "./map-sync";
export { default as ABCompareStory } from "./ab-compare";
export { default as ConfigObjectStory } from "./config-object";
export { default as ProjectionStory } from "./projection";
+export { default as DimensionChangeStory } from "./switch-dimentions";
export { default as ProjectionTransformStory } from "./projection-transform";
export { default as AnimationsStory } from "./animations";
export { default as PreventScrollStory } from "./prevent-scroll";
@@ -30,3 +31,4 @@ export { default as FlatGeoBufStory } from "./flatGeoBuf-layer";
export { default as CustomTooltipStory } from "./custom-tooltip";
export { default as GetFeatureInfoTooltipStory } from "./getFeature-tooltip";
export { default as CoordinatesCustomTooltipsStory } from "./coordinates-custom-tooltips";
+export { default as GlobeStory } from "./globe";
diff --git a/elements/map/stories/map.stories.js b/elements/map/stories/map.stories.js
index b4b0ff26c..35a31996b 100644
--- a/elements/map/stories/map.stories.js
+++ b/elements/map/stories/map.stories.js
@@ -28,12 +28,14 @@ import {
ConfigObjectStory,
ProjectionStory,
ProjectionTransformStory,
+ DimensionChangeStory,
AnimationsStory,
PreventScrollStory,
FlatGeoBufStory,
CustomTooltipStory,
GetFeatureInfoTooltipStory,
CoordinatesCustomTooltipsStory,
+ GlobeStory,
} from "./index.js";
export default {
@@ -46,6 +48,7 @@ export default {
.center=${args.center}
.controls=${args.controls}
.layers=${args.layers}
+ .projection=${args.projection}
.zoom=${args.zoom}
>
`,
@@ -250,6 +253,13 @@ export const Projection = ProjectionStory;
*/
export const ProjectionTransform = ProjectionTransformStory;
+/**
+ * The dimension of the view can be changed via the `dimension`-attribute.
+ * 2D map in EPSG:4326 (geographic coordinates) and Globe view
+ * are included.
+ */
+export const DimensionChange = DimensionChangeStory;
+
/**
* changing the properties `zoom`, `center` or `zoomExtent` will trigger animations, if the
* `animationOptions`-property is set.
@@ -264,3 +274,8 @@ export const Animations = AnimationsStory;
* Useful for maps embedded in scrollable websites.
*/
export const PreventScroll = PreventScrollStory;
+
+/**
+ * Basic Globe rendered using `projection: "globe"`
+ */
+export const Globe = GlobeStory;
diff --git a/elements/map/stories/switch-dimentions.js b/elements/map/stories/switch-dimentions.js
new file mode 100644
index 000000000..b6e81ae40
--- /dev/null
+++ b/elements/map/stories/switch-dimentions.js
@@ -0,0 +1,55 @@
+import { html } from "lit";
+
+/**
+ * The projection of the view can be changed via the `projection`-attribute.
+ *
+ * @returns {Object} The story configuration with arguments for the component.
+ */
+const DimensionChangeStory = {
+ args: {
+ layers: [
+ {
+ type: "Tile",
+ properties: {
+ id: "osm",
+ title: "Background",
+ },
+ source: { type: "OSM" },
+ },
+ ],
+ center: [0, 0],
+ zoom: 4,
+ projection: "EPSG:4326",
+ },
+ render: /** @param {Object.} args **/ (args) => html`
+
+
+
+ `,
+};
+
+export default DimensionChangeStory;