manzt / anywidget

jupyter widgets made easy
https://anywidget.dev
MIT License
460 stars 37 forks source link

Creating map widgets #25

Closed giswqs closed 1 year ago

giswqs commented 1 year ago

I am interested in creating a map widget based on the leaflet javascript library. Here is an example. I am not a JavaScript expert. Do you know why the map widget does not show up?

https://leafletjs.com/examples/quick-start/example-overlays.html

import anywidget
import traitlets

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

    import * as L from "https://unpkg.com/leaflet@1.9.3/dist/leaflet.js"
    export function render(view) {

        // create a div element
        const container = document.createElement("div");
        container.id = "map";
        container.style.height = "300px";

        // create a Leaflet map
        const map = L.map(container).setView([51.505, -0.09], 13);
        L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
        attribution:
            'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors',
        maxZoom: 18,
        }).addTo(map);

        canvas.appendChild(map)
        view.el.appendChild(canvas);
        }
    """
MapWidget()
manzt commented 1 year ago

Hi, thanks for trying out anywidget! Widgets not showing up typically means there are errors in your JavaScript, which generally fall into 3 categories:

  1. Failed import statements (i.e., attempting to import JavaScript that is not an ECMAScript module)
  2. Reference errors (i.e., trying to assign or use variables that are not defined)
  3. Syntax errors (just malformatted code)
  4. Incorrect logic or use of APIs

There is probably a way that anywidget could try to communicate these errors, minus 4, but it is intentionally just a wrapper around Jupyter Widgets (so it inherits the same error handling mechanism).

With that said, the snippet you shared contains issues 1, 2, and 4 above. First, the Leaflet JavaScript you are attempting to import is not ESM, but a UMD script that adds Leaflet as a global. Second, your render function contains a reference to a variable named canvas which does not exist. Third, map is not a JavaScript element and cannot be appended to view.el. Finally, Leaflet's docs state that you must "include Leaflet CSS file in the head section of your document", which you also have not done. Here is a working snippet,

import anywidget
import traitlets

class MapWidget(anywidget.AnyWidget):
    # Widget front-end JavaScript code
    _esm = """
    // import ESM version of Leaflet
    import * as L from "https://unpkg.com/leaflet@1.9.3/dist/leaflet-src.esm.js";
    export function render(view) {
            // create a div element
            const container = document.createElement("div");
            container.id = "map";
            container.style.height = "300px";
            container.style.width = "500px"; 
            // create a Leaflet map
            const map = L.map(container).setView([51.505, -0.09], 13);
            L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
                attribution: 'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors',
                maxZoom: 18,
            }).addTo(map);
            view.el.appendChild(container);
        }
    """
    # make sure to include styles
    _css = "https://unpkg.com/leaflet@1.9.3/dist/leaflet.css"

MapWidget()

I know that you, and many potential anywidget users, are likely not JavaScript experts but it's a great way learn some front end skills! I have answered this question in detail so that I can point others to the thread, but GitHub issues should be used to surface bugs/issues with anywidget and not for debugging custom JavaScript.

giswqs commented 1 year ago

@manzt Many thanks for the detailed explanation and the working solution. Much appreciated.

I did not intend to open an issue for this. Since the Discussion board is not enabled for the repo, that's why I had to open an issue for asking this question. Appreciate your help.

manzt commented 1 year ago

You bet! It might be worth learning more about ESM if you are unfamiliar, since you are trying to use a third-party dependency in your project and knowing the basics would be helpful. Second, I recommend opening the browser console when developing a widget (right click webpage + Inspect)! You can see errors on the page and console.log() from your _esm and see values right there:

image
giswqs commented 1 year ago

Awesome! Thank you for the tips. Your widget inspires me to learn the front-end stuff. It has opened a new door for me to explore all kinds of JavaScript libraries in Python. Hope to contribute to Anywidget in the near future.

giswqs commented 1 year ago

Would you mind me asking a follow-up question? Is it possible to add a method to the widget class that can modify the javascript object initiated through the esm module? For example, I would like to add an add_layer() method to MapWidget so that I can add a layer to the map after the map creation. Is this possible? I have been searching for a solution for hours without much success. Any advice would be appreciated.

import anywidget
import traitlets

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

    _esm = """
    // import ESM version of Leaflet
    import * as L from "https://unpkg.com/leaflet@1.9.3/dist/leaflet-src.esm.js";
    export function render(view) {
            // create a div element
            let center = view.model.get("center");
            let zoom = view.model.get("zoom");
            const container = document.createElement("div");
            container.id = "map";
            container.style.height = "600px";
            // container.style.width = "500px"; 
            // create a Leaflet map
            const map = L.map(container).setView(center, zoom);
            L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
                attribution: 'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors',
                maxZoom: 18,
            }).addTo(map);
            view.el.appendChild(container);
        }
    """

    # make sure to include styles
    _css = "https://unpkg.com/leaflet@1.9.3/dist/leaflet.css"
    center = traitlets.List([40, -100]).tag(sync=True, o=True)
    zoom = traitlets.Int(4).tag(sync=True, o=True)

    # add a layer
    def add_layer(self, url=None):

        if url is None:
            url = 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}'

        self.send({"type": "add_layer", "url": url, "attribution": "Google"})

m = MapWidget(center=[40, -100], zoom=4)
m
# m.add_layer()
manzt commented 1 year ago

Would you mind me asking a follow-up question?

Sure thing.

EDIT: I have moved and edited the prior content from here to Jupyter Widgets: The Good Parts in the anywidget documentation. I have left the recommendation for your use case below, but recommend reading the new docs.

Recommendation

Traitlets should be preferred over custom messages since widget state can be fully serialized and recreated without Python running. Write logic that treats your view.model as the source of truth (see the anywidget Two-Way Data-Binding Example.

For your MapWidget I'd recommend thinking about representing the layers as a traitlet and then writing a render function that creates and modifies the map object in the client. e.g.,

class MapWidget(anywidget.AnyWidget):
    # ...
    _layers = traitlets.List(traitlets.Dict()).tag(sync=True)

    def add_layer(self, url = "...", attribution = "... "):
        self._layers = self._layers + dict(url=url, attribution=attribution) # must re-assign to trigger update (cannot mutate)

An alternative (and probably more simple JavaScript) is to use custom messages like you have before, but just note that add_layer must be called after the widget is displayed.

import anywidget
import traitlets

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

    _esm = """
    // import ESM version of Leaflet
    import * as L from "https://unpkg.com/leaflet@1.9.3/dist/leaflet-src.esm.js";
    export function render(view) {
            // create a div element
            let center = view.model.get("center");
            let zoom = view.model.get("zoom");

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

            // create a Leaflet map
            const map = L.map(container).setView(center, zoom);
            L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
                attribution: 'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors',
                maxZoom: 18,
            }).addTo(map);

            view.model.on("msg:custom", (msg) => {
                switch (msg.type) {
                    case "add_layer":
                        L.tileLayer(msg.url, { attribution: msg.attribution }).addTo(map);
                        break;
                    default:
                        console.err(`Unsupported message '${msg.type}'.`);
                }
                console.log(data);
            });
            view.el.appendChild(container);
        }
    """

    # make sure to include styles
    _css = "https://unpkg.com/leaflet@1.9.3/dist/leaflet.css"
    center = traitlets.List([40, -100]).tag(sync=True, o=True)
    zoom = traitlets.Int(4).tag(sync=True, o=True)

    # add a layer
    def add_layer(self, url=None):

        if url is None:
            url = 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}'

        self.send({"type": "add_layer", "url": url, "attribution": "Google"})

m = MapWidget(center=[40, -100], zoom=4)
m
# m.add_layer()
giswqs commented 1 year ago

@manzt Thank you very much again for the detailed explanation. This is exactly what I was looking for. I have created a mapwidget Python package that builds upon anywidget. Looking forward to exploring anywidget more and adding new features to mapwidget. Really appreciate your awesome work and patience in answering questions!