Skip to content
1 change: 1 addition & 0 deletions .storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions elements/map/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion elements/map/src/custom/sources/WMTSCapabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}

/**
Expand Down
2 changes: 1 addition & 1 deletion elements/map/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ export class EOxMap extends LitElement {
${eoxStyle}
${controlCss}
</style>
<div style="width: 100%; height: 100%"></div>
<div id="map" style="width: 100%; height: 100%"></div>
<slot></slot>
`;
}
Expand Down
10 changes: 9 additions & 1 deletion elements/map/src/methods/map/first-updated.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
66 changes: 66 additions & 0 deletions elements/map/src/methods/map/setters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
47 changes: 47 additions & 0 deletions elements/map/src/plugins/globe/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
177 changes: 177 additions & 0 deletions elements/map/src/plugins/globe/methods.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading