opengeos / leafmap

A Python package for interactive mapping and geospatial analysis with minimal coding in a Jupyter environment
https://leafmap.org
MIT License
3.22k stars 386 forks source link

Ability to switch between 2D and 3D #390

Closed dahlalex closed 1 year ago

dahlalex commented 1 year ago

A suggestion could be an integration with

1

giswqs commented 1 year ago

I thought about this before. Unfortunately, cesium doesn't have a Python API/package. It won't be easy for leafmap to support cesium. Suggestions are welcome.

giswqs commented 1 year ago

Anywidget can potentially make CesiumJS work with Jupyter. Here is an example I just tried. It does not work yet. I am not a JavaScript expert and I am not sure how to make it work yet. @manzt might be able to shed some lights on this.

https://cesium.com/learn/cesiumjs-learn/cesiumjs-quickstart/

import anywidget
import traitlets

class MapWidget(anywidget.AnyWidget):
    # Widget front-end JavaScript code

    _esm = """
    import * as Cesium from "https://cesium.com/downloads/cesiumjs/releases/1.103/Build/Cesium/Cesium.js";
    export function render(view) {
            // create a div element

            const cesiumContainer = document.createElement("div");            
            cesiumContainer.style.height = "600px";

            Cesium.Ion.defaultAccessToken = 'your_access_token';

            // Initialize the Cesium Viewer in the HTML element with the `cesiumContainer` ID.
            const viewer = new Cesium.Viewer('cesiumContainer', {
            terrainProvider: Cesium.createWorldTerrain()
            });    
            // Add Cesium OSM Buildings, a global 3D buildings layer.
            const buildingTileset = viewer.scene.primitives.add(Cesium.createOsmBuildings());   
            // Fly the camera to San Francisco at the given longitude, latitude, and height.
            viewer.camera.flyTo({
            destination : Cesium.Cartesian3.fromDegrees(-122.4175, 37.655, 400),
            orientation : {
                heading : Cesium.Math.toRadians(0.0),
                pitch : Cesium.Math.toRadians(-15.0),
            }
            });
            view.el.appendChild(cesiumContainer);
        }
    """
    # make sure to include styles
    _css = "https://cesium.com/downloads/cesiumjs/releases/1.103/Build/Cesium/Widgets/widgets.css"

m = MapWidget()
m
image
manzt commented 1 year ago

The issue is the JavaScript code is not an ESM, but rather UMD bundle that is intended to be loaded from a <script> tag. It some JavaScript that when loaded has the side effect of mutating the global namespace by appending Cesium. This is an old style of shipping JavaScript via a CDN that relies on convention over an official standard like ESM.

It would be nice for Cesium to ship ESM through their CDN since it is an official modern standard, and mutating the global namespace generally a poor practice when there is other JS on the page. However, there is a workaround to load this type of JS with a little JavaScript:

 function loadScript(src) {
  return new Promise((resolve, reject) => {
    let script = Object.assign(document.createElement("script"), {
      type: "text/javascript",
      async: true,
      src: src,
    });
    script.addEventListener("load", resolve);
    script.addEventListener("error", reject);
    document.body.appendChild(script);
  });
};

The loadScript function returns a Promise object that resolves when the script is successfully loaded, or rejects when there is an error loading the script. This ensures that we "wait" until the Cesium global is available before executing render. A deeper explanation/more robust loadScript function can be found in this post for a deeper explanation.

A full example,

import anywidget
import traitlets

class MapWidget(anywidget.AnyWidget):
    # Widget front-end JavaScript code

    _esm = """
    function loadScript(src) {
      return new Promise((resolve, reject) => {
        let script = Object.assign(document.createElement("script"), {
          type: "text/javascript",
          async: true,
          src: src,
        });
        script.addEventListener("load", resolve);
        script.addEventListener("error", reject);
        document.body.appendChild(script);
      });
    };

    await loadScript("https://cesium.com/downloads/cesiumjs/releases/1.103/Build/Cesium/Cesium.js");

    export function render(view) {
            // create a div element

            const cesiumContainer = document.createElement("div");            
            cesiumContainer.style.height = "600px";

            Cesium.Ion.defaultAccessToken = 'your_access_token';

            // Initialize the Cesium Viewer in the HTML element with the `cesiumContainer` ID.
            const viewer = new Cesium.Viewer('cesiumContainer', {
            terrainProvider: Cesium.createWorldTerrain()
            });    
            // Add Cesium OSM Buildings, a global 3D buildings layer.
            const buildingTileset = viewer.scene.primitives.add(Cesium.createOsmBuildings());   
            // Fly the camera to San Francisco at the given longitude, latitude, and height.
            viewer.camera.flyTo({
            destination : Cesium.Cartesian3.fromDegrees(-122.4175, 37.655, 400),
            orientation : {
                heading : Cesium.Math.toRadians(0.0),
                pitch : Cesium.Math.toRadians(-15.0),
            }
            });
            view.el.appendChild(cesiumContainer);
        }
    """
    # make sure to include styles
    _css = "https://cesium.com/downloads/cesiumjs/releases/1.103/Build/Cesium/Widgets/widgets.css"

m = MapWidget()
m

The above now raises an exception for an invalid access token:

image
giswqs commented 1 year ago

@manzt Thank you very much for providing the solution. This looks very promising. I signed up for a Cesium account and got an access token for using with the example. I then tried it on Colab, Jupyter Lab, Jupyter Notebook. All of them ran into errors like this:

image
manzt commented 1 year ago

Sorry, I don't have the time to look into this more deeply. It seems like some issue with Cesium JS looking for a global define (typically from requirejs), which may or may not be present in the notebook environment. This issue stems from Cesium not shipping ESM, so there isn't really anything we can do on the anywidget side unfortunately.

manzt commented 1 year ago

Ok, I actually looked into this. I'm guessing the error you shared above is from Jupyter Notebooks (which uses requirejs and messes up this define thing). However, the following works in JupyterLab:

import anywidget
import traitlets

class MapWidget(anywidget.AnyWidget):
    # Widget front-end JavaScript code

    _esm = """
    function loadScript(src) {
      return new Promise((resolve, reject) => {
        let script = Object.assign(document.createElement("script"), {
          type: "text/javascript",
          async: true,
          src: src,
        });
        script.addEventListener("load", resolve);
        script.addEventListener("error", reject);
        document.body.appendChild(script);
      });
    };

    await loadScript("https://cesium.com/downloads/cesiumjs/releases/1.103/Build/Cesium/Cesium.js");

    export function render(view) {
      const div = document.createElement("div");
      div.style.width = "500px";
      div.style.height = "500px";

      Cesium.Ion.defaultAccessToken = view.model.get("token");

      const viewer = new Cesium.Viewer(div, {
        terrainProvider: Cesium.createWorldTerrain()
      });    

      const buildingTileset = viewer.scene.primitives.add(Cesium.createOsmBuildings());   

      viewer.camera.flyTo({
        destination : Cesium.Cartesian3.fromDegrees(-122.4175, 37.655, 400),
        orientation : {
          heading : Cesium.Math.toRadians(0.0),
          pitch : Cesium.Math.toRadians(-15.0),
        }
      });

      view.el.appendChild(div);
    }
    """
    # make sure to include styles
    _css = "https://cesium.com/downloads/cesiumjs/releases/1.103/Build/Cesium/Widgets/widgets.css"
    token = traitlets.Unicode().tag(sync=True)

m = MapWidget(token="YOUR_TOKEN")
m
image

The errors you got before in Colab and JupyterLab arise from this line in the js:

            const viewer = new Cesium.Viewer('cesiumContainer', {

where "esiumContainer" is an ID for a <div> in the Cesium example. You haven't created that element, so Cesium throws and error. Remember that any time you use a JS library in the browser, there is typically some point where you need to connect the JS to a DOM node.

giswqs commented 1 year ago

@manzt Amazing work! The ability to use non-ESM javascript library is very exciting. I will explore this and see how it can be integrated into leafmap.

giswqs commented 1 year ago

@manzt Anywidget's hot module replacement functionality is mind-blowing! Thank you.

Peek 2023-03-17 23-17

giswqs commented 1 year ago

Another example using the maplibre library.

import anywidget
import traitlets

class MapWidget(anywidget.AnyWidget):
    _esm = """
    function loadScript(src) {
      return new Promise((resolve, reject) => {
        let script = Object.assign(document.createElement("script"), {
          type: "text/javascript",
          async: true,
          src: src,
        });
        script.addEventListener("load", resolve);
        script.addEventListener("error", reject);
        document.body.appendChild(script);
      });
    };

    await loadScript("https://unpkg.com/maplibre-gl@2.4.0/dist/maplibre-gl.js");

    export function render(view) {
      const div = document.createElement("div");
      div.style.width = "100%";
      div.style.height = "500px";

        var map = new maplibregl.Map({
        container: div,
        style: 'https://demotiles.maplibre.org/style.json', // stylesheet location
        center: [0, 20], // starting position [lng, lat]
        zoom: 2 // starting zoom
        });
      view.el.appendChild(div);
    }
    """
    # make sure to include styles
    _css = "https://unpkg.com/maplibre-gl@2.4.0/dist/maplibre-gl.css"
    token = traitlets.Unicode().tag(sync=True)

m = MapWidget()
m
image
giswqs commented 1 year ago

Another example using the mapbox library. Anywdiget has unlimited potential!

import anywidget
import traitlets

class MapWidget(anywidget.AnyWidget):
    _esm = """
    function loadScript(src) {
      return new Promise((resolve, reject) => {
        let script = Object.assign(document.createElement("script"), {
          type: "text/javascript",
          async: true,
          src: src,
        });
        script.addEventListener("load", resolve);
        script.addEventListener("error", reject);
        document.body.appendChild(script);
      });
    };

    await loadScript("https://api.mapbox.com/mapbox-gl-js/v2.13.0/mapbox-gl.js");

    export function render(view) {
      const div = document.createElement("div");
      div.style.width = "100%";
      div.style.height = "500px";

      let token = view.model.get("token");

    mapboxgl.accessToken = token
    const map = new mapboxgl.Map({
    container: div,
    style: 'mapbox://styles/mapbox/streets-v12',
    center: [-100, 40],
    zoom: 2
    });
      view.el.appendChild(div);
    }
    """
    # make sure to include styles
    _css = "https://api.mapbox.com/mapbox-gl-js/v2.13.0/mapbox-gl.css"
    token = traitlets.Unicode().tag(sync=True)

token = 'YOUR-TOKEN'
m = MapWidget(token=token)
m
image
giswqs commented 1 year ago

An example using the OpenLayers library.

import anywidget
import traitlets

class MapWidget(anywidget.AnyWidget):
    _esm = """
    function loadScript(src) {
      return new Promise((resolve, reject) => {
        let script = Object.assign(document.createElement("script"), {
          type: "text/javascript",
          async: true,
          src: src,
        });
        script.addEventListener("load", resolve);
        script.addEventListener("error", reject);
        document.body.appendChild(script);
      });
    };

    await loadScript("https://cdn.jsdelivr.net/npm/ol@v7.3.0/dist/ol.js");

    export function render(view) {
      const div = document.createElement("div");
      div.style.width = "100%";
      div.style.height = "500px";

        var map = new ol.Map({
            target: div,
            layers: [
                new ol.layer.Tile({
                    source: new ol.source.OSM()
                })
            ],
            view: new ol.View({
                center: ol.proj.fromLonLat([0, 20]),
                zoom: 2
            })
        });
      view.el.appendChild(div);
    }
    """
    # make sure to include styles
    _css = "https://cdn.jsdelivr.net/npm/ol@v7.3.0/ol.css"
    token = traitlets.Unicode().tag(sync=True)

m = MapWidget()
m

image

giswqs commented 1 year ago

3D mapping functionality will be available through the mapwidget package. https://github.com/opengeos/mapwidget