From 8a78512956d6facf8bdd126731bd4a7f5c9f17b4 Mon Sep 17 00:00:00 2001 From: Bane Sullivan Date: Sat, 20 Jul 2024 17:31:55 -0700 Subject: [PATCH 1/3] [wip] add anywidget for viewer without jupyter-server-proxy --- localtileserver/client.py | 14 +++++ localtileserver/custom/__init__.py | 0 localtileserver/custom/lts_widget.css | 1 + localtileserver/custom/lts_widget.js | 89 +++++++++++++++++++++++++++ localtileserver/custom/widgets.py | 76 +++++++++++++++++++++++ 5 files changed, 180 insertions(+) create mode 100644 localtileserver/custom/__init__.py create mode 100644 localtileserver/custom/lts_widget.css create mode 100644 localtileserver/custom/lts_widget.js create mode 100644 localtileserver/custom/widgets.py diff --git a/localtileserver/client.py b/localtileserver/client.py index f9725f32..0290bfb5 100644 --- a/localtileserver/client.py +++ b/localtileserver/client.py @@ -7,6 +7,10 @@ import requests from rio_tiler.io import Reader +try: + import anywidget +except ImportError: # pragma: no cover + anywidget = None try: import ipyleaflet except ImportError: # pragma: no cover @@ -508,6 +512,16 @@ def _ipython_display_(self): m.add(t) return display(m) + elif anywidget: + + def _ipython_display_(self): + from IPython.display import display + + from localtileserver.custom.widgets import TileLayerWidget + + w = TileLayerWidget(self) + return display(w) + class TileClient(TilerInterface, TileServerMixin): """Tile client interface for generateing and serving tiles. diff --git a/localtileserver/custom/__init__.py b/localtileserver/custom/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/localtileserver/custom/lts_widget.css b/localtileserver/custom/lts_widget.css new file mode 100644 index 00000000..61f3f5a3 --- /dev/null +++ b/localtileserver/custom/lts_widget.css @@ -0,0 +1 @@ +@import url("https://unpkg.com/leaflet@1.9.3/dist/leaflet.css"); diff --git a/localtileserver/custom/lts_widget.js b/localtileserver/custom/lts_widget.js new file mode 100644 index 00000000..a0902463 --- /dev/null +++ b/localtileserver/custom/lts_widget.js @@ -0,0 +1,89 @@ +// import ESM version of Leaflet +import * as L from "https://unpkg.com/leaflet@1.9.3/dist/leaflet-src.esm.js"; + +function render({ model, el, experimental }) { + // Header + let bounds = model.get("bounds"); // TODO: use these + let identifier = model.get("identifier"); + + console.log("Rendering localtileserver widget", identifier, bounds); + + const tileHandlers = {}; + + L.TileLayer.LocalTileLayer = L.TileLayer.extend({ + initialize: function (options) { + L.TileLayer.prototype.initialize.call(this, options); + this.identifier = options.identifier; + }, + createTile: function (coords, done) { + // leaflet's calls cannot be async, so must use a callback to modify the img element + var tile = document.createElement('img'); + + // ID unique to this single tile request + const id = Math.random().toString(36).substring(7); + + function handleTile(msg, buffers) { + if (msg.id !== id) return; + // console.log(msg) + if (msg.response === "success") { + let blob = new Blob([buffers[0]], { type: "image/png" }); + let url = URL.createObjectURL(blob); + tile.src = url; + } + done(null, tile); + delete tileHandlers[id]; + // console.log(Object.keys(tileHandlers).length) + } + + tileHandlers[id] = handleTile; + + model.send( + { id, kind: "anywidget-command", name: "get_tile", msg: {...coords, identifier} }, + undefined, + [], + ); + + return tile; + }, + getAttribution: function() { + return "Raster file served by localtileserver." + } + }); + + L.tileLayer.localTileLayer = function(options) { + return new L.TileLayer.LocalTileLayer(options); + }; + + function tileHandler(msg, buffers) { + if (msg.id in tileHandlers) { + tileHandlers[msg.id](msg, buffers); + } + } + model.on("msg:custom", tileHandler); + + var layer = L.tileLayer.localTileLayer({ identifier: identifier }); + console.log(layer) + + // TODO: add tileLayer to map in ipyleaflet or folium + // For now, here is a custom Leaflet map + + const container = document.createElement("div"); + container.style.width = '100%'; + container.style.height = '600px'; + + const center = model.get("center"); // center of raster + const zoom = model.get("default_zoom"); + const map = L.map(container).setView(center, zoom); + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: + 'Map data © OpenStreetMap contributors', + maxZoom: 18, + }).addTo(map); + + layer.addTo(map); + el.appendChild(container); + setTimeout(function(){ map.invalidateSize()}, 400); + +} + +export default { render }; diff --git a/localtileserver/custom/widgets.py b/localtileserver/custom/widgets.py new file mode 100644 index 00000000..f6079886 --- /dev/null +++ b/localtileserver/custom/widgets.py @@ -0,0 +1,76 @@ +import concurrent.futures +import os +import pathlib +import uuid + +import anywidget +import traitlets + +from localtileserver.client import TileClient + +THIS_DIR = pathlib.Path(__file__).parent.absolute() +THREAD_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=os.cpu_count()) + + +def handle_anywidget_command(widget, msg: str | list | dict, buffers: list[bytes]) -> None: + if not isinstance(msg, dict) or msg.get("kind") != "anywidget-command": + return + + def target(): + response, buffers = widget.get_tile(msg["msg"]) + widget.send( + { + "id": msg["id"], + "kind": "anywidget-command-response", + "response": response, + }, + buffers, + ) + + # t = threading.Thread(target=target) + # t.start() + THREAD_EXECUTOR.submit(target) + + +class TileLayerWidget(anywidget.AnyWidget): + """Leaflet Tile Layer Widget. + + Args: + anywidget (_type_): _description_ + """ + + _esm = THIS_DIR / "lts_widget.js" + _css = THIS_DIR / "lts_widget.css" + + bounds = traitlets.List([0, 0, 0, 0]).tag(sync=True, o=True) + center = traitlets.List([0, 0]).tag(sync=True, o=True) + identifier = traitlets.Unicode("").tag(sync=True, o=True) + default_zoom = traitlets.Int(0).tag(sync=True, o=True) + + def __init__(self, client: TileClient, **kwargs): + super().__init__() + self.client = client # TODO: weakref? + self.on_msg(handle_anywidget_command) + self._display_kwargs = kwargs + self.bounds = self.client.bounds() + self.identifier = uuid.uuid4().hex + self.center = self.client.center() + self.default_zoom = self.client.default_zoom + + def get_tile(self, msg): + try: + x = int(msg["x"]) + y = int(msg["y"]) + z = int(msg["z"]) + except (KeyError, ValueError) as e: + return f"failed: {e}", [] + + try: + # NOTE: when calling from a thread executor, we need a different dataset handle for each thread + # finding that the kernel crashes... is rio-tiler or rasterio not thread safe? + c = TileClient(self.client.dataset.name) + tile = c.tile(z=z, x=x, y=y, **self._display_kwargs) + except Exception as e: + return f"failed {e}", [b""] + + return "success", [tile] From 38582ee83d47a9210ffd3fe4a69be68b60d611a9 Mon Sep 17 00:00:00 2001 From: Bane Sullivan Date: Sat, 20 Jul 2024 17:36:08 -0700 Subject: [PATCH 2/3] Disable ipyleaflet ipython display --- localtileserver/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/localtileserver/client.py b/localtileserver/client.py index 0290bfb5..bd4b75df 100644 --- a/localtileserver/client.py +++ b/localtileserver/client.py @@ -500,7 +500,7 @@ def get_leaflet_map(self, add_bounds: bool = False, **kwargs): m.add(wlayer) return m - if ipyleaflet: + if False: # ipyleaflet: def _ipython_display_(self): from IPython.display import display From e543d12bf95a5f114995579137a3f9cad710c913 Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Sun, 25 Aug 2024 20:20:44 -0400 Subject: [PATCH 3/3] Replace `anywidget-command` kind (#220) refactor: Remove use of anywidget-command kind --- localtileserver/custom/lts_widget.js | 2 +- localtileserver/custom/widgets.py | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/localtileserver/custom/lts_widget.js b/localtileserver/custom/lts_widget.js index a0902463..1038a49c 100644 --- a/localtileserver/custom/lts_widget.js +++ b/localtileserver/custom/lts_widget.js @@ -38,7 +38,7 @@ function render({ model, el, experimental }) { tileHandlers[id] = handleTile; model.send( - { id, kind: "anywidget-command", name: "get_tile", msg: {...coords, identifier} }, + { id, kind: "get_tile", msg: {...coords, identifier} }, undefined, [], ); diff --git a/localtileserver/custom/widgets.py b/localtileserver/custom/widgets.py index f6079886..6bc2241d 100644 --- a/localtileserver/custom/widgets.py +++ b/localtileserver/custom/widgets.py @@ -12,16 +12,14 @@ THREAD_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=os.cpu_count()) -def handle_anywidget_command(widget, msg: str | list | dict, buffers: list[bytes]) -> None: - if not isinstance(msg, dict) or msg.get("kind") != "anywidget-command": - return +def handle_custom_message(widget, msg: dict, buffers: list[bytes]) -> None: + assert msg["kind"] == "get_tile", f"unexpected message kind: {msg['kind']}" def target(): response, buffers = widget.get_tile(msg["msg"]) widget.send( { "id": msg["id"], - "kind": "anywidget-command-response", "response": response, }, buffers, @@ -36,7 +34,7 @@ class TileLayerWidget(anywidget.AnyWidget): """Leaflet Tile Layer Widget. Args: - anywidget (_type_): _description_ + client (TileClient): A TileClient instance. """ _esm = THIS_DIR / "lts_widget.js" @@ -50,7 +48,7 @@ class TileLayerWidget(anywidget.AnyWidget): def __init__(self, client: TileClient, **kwargs): super().__init__() self.client = client # TODO: weakref? - self.on_msg(handle_anywidget_command) + self.on_msg(handle_custom_message) self._display_kwargs = kwargs self.bounds = self.client.bounds() self.identifier = uuid.uuid4().hex