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;