diff --git a/docs/environment.yml b/docs/environment.yml index 0320a4c93..7d3f049bb 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -11,6 +11,7 @@ dependencies: - ypywidgets>=0.9.6,<0.10.0 - comm>=0.1.2,<0.2.0 - pydantic>=2,<3 + - sidecar - pip: - yjs-widgets>=0.4,<0.5 - my-jupyter-shared-drive<0.2.0 diff --git a/docs/user_guide/python_api.md b/docs/user_guide/python_api.md index 0a7c56cdf..913cc6bf3 100644 --- a/docs/user_guide/python_api.md +++ b/docs/user_guide/python_api.md @@ -34,7 +34,13 @@ doc Once the document is opened/created, you can start creating GIS layers. -## `GISDocument` API Reference +## `explore` + +```{eval-rst} +.. autofunction:: jupytergis_lab.explore +``` + +## `GISDocument` ```{eval-rst} .. autoclass:: jupytergis_lab.GISDocument diff --git a/examples/explore.ipynb b/examples/explore.ipynb new file mode 100644 index 000000000..cc1398245 --- /dev/null +++ b/examples/explore.ipynb @@ -0,0 +1,111 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c3f4096f-cbd3-43e8-a986-520681f03581", + "metadata": {}, + "source": [ + "# ESPM 157 - Intro to Spatial Data\n", + "\n", + "\n", + "\n", + "Install dependencies:\n", + "\n", + "```bash\n", + "micromamba install geopandas ibis-duckdb\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b76ae1f0-334e-41c0-9533-407c879b4ad6", + "metadata": {}, + "outputs": [], + "source": [ + "import ibis\n", + "\n", + "con = ibis.duckdb.connect()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "837aa4e0-5eeb-48c1-97ce-3f8706503911", + "metadata": {}, + "outputs": [], + "source": [ + "redlines = con.read_geo(\n", + " \"/vsicurl/https://dsl.richmond.edu/panorama/redlining/static/mappinginequality.gpkg\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4d3319fa-b3b4-4931-b073-98b649e41b65", + "metadata": {}, + "outputs": [], + "source": [ + "city = redlines.filter(redlines.city == \"New Haven\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34f7f4d6-28d6-4716-8e03-ac32c6ae3bb7", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "city_gdf = city.head().execute()\n", + "city_gdf.plot()" + ] + }, + { + "cell_type": "markdown", + "id": "d37a610a-1444-43a3-900f-dfe16a890ab9", + "metadata": {}, + "source": [ + "## OK, but what about spatial context?\n", + "\n", + "I want to explore this data more interactively." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6e68aaac-dfc0-42d8-a3b9-05fce59524b2", + "metadata": {}, + "outputs": [], + "source": [ + "from jupytergis import explore\n", + "\n", + "# Open a new exploration window\n", + "explore(city_gdf, layer_name=\"New Haven\", basemap=\"dark\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/lite/environment.yml b/lite/environment.yml index 55e62479f..db2d857b4 100644 --- a/lite/environment.yml +++ b/lite/environment.yml @@ -11,6 +11,7 @@ dependencies: - ypywidgets>=0.9.6,<0.10.0 - comm>=0.1.2,<0.2.0 - pydantic>=2,<3 + - sidecar - pip: - yjs-widgets>=0.4,<0.5 - my-jupyter-shared-drive<0.2.0 diff --git a/packages/base/src/panelview/components/layers.tsx b/packages/base/src/panelview/components/layers.tsx index 7e7238cac..7e64e9d99 100644 --- a/packages/base/src/panelview/components/layers.tsx +++ b/packages/base/src/panelview/components/layers.tsx @@ -171,6 +171,7 @@ function LayersBodyComponent(props: IBodyProps): JSX.Element { model?.sharedModel.layersChanged.connect(updateLayers); model?.sharedModel.layerTreeChanged.connect(updateLayers); + updateLayers(); return () => { model?.sharedModel.layersChanged.disconnect(updateLayers); model?.sharedModel.layerTreeChanged.disconnect(updateLayers); diff --git a/python/jupytergis/jupytergis/__init__.py b/python/jupytergis/jupytergis/__init__.py index 3097075a4..139b9e470 100644 --- a/python/jupytergis/jupytergis/__init__.py +++ b/python/jupytergis/jupytergis/__init__.py @@ -1,3 +1,3 @@ __version__ = "0.4.4" -from jupytergis_lab import GISDocument # noqa +from jupytergis_lab import GISDocument, explore # noqa diff --git a/python/jupytergis_lab/jupytergis_lab/__init__.py b/python/jupytergis_lab/jupytergis_lab/__init__.py index 0d4f3c58f..8addf6db5 100644 --- a/python/jupytergis_lab/jupytergis_lab/__init__.py +++ b/python/jupytergis_lab/jupytergis_lab/__init__.py @@ -9,6 +9,7 @@ __version__ = "dev" from .notebook import GISDocument # noqa +from .notebook.explore import explore def _jupyter_labextension_paths(): diff --git a/python/jupytergis_lab/jupytergis_lab/notebook/explore.py b/python/jupytergis_lab/jupytergis_lab/notebook/explore.py new file mode 100644 index 000000000..c20aad040 --- /dev/null +++ b/python/jupytergis_lab/jupytergis_lab/notebook/explore.py @@ -0,0 +1,127 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, Optional +import re + +from jupytergis_lab import GISDocument + + +@dataclass +class Basemap: + name: str + url: str + + +BasemapChoice = Literal["light", "dark", "topo"] +_basemaps: dict[BasemapChoice, list[Basemap]] = { + "light": [ + Basemap( + name="ArcGIS dark basemap", + url="https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}.pbf", + ), + Basemap( + name="ArcGIS dark basemap reference", + url="https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}.pbf", + ), + ], + "dark": [ + Basemap( + name="ArcGIS light basemap", + url="https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}.pbf", + ), + Basemap( + name="ArcGIS light basemap reference", + url="https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Reference/MapServer/tile/{z}/{y}/{x}.pbf", + ), + ], + "topo": [ + Basemap( + name="USGS topographic basemap", + url="https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/tile/{z}/{y}/{x}", + ), + ], +} + + +def explore( + data: str | Path | Any, + *, + layer_name: Optional[str] = "Exploration layer", + basemap: BasemapChoice = "topo", +) -> GISDocument: + """Run a JupyterGIS data interaction interface alongside a Notebook. + + :param data: A GeoDataFrame or path to a GeoJSON file. + + :raises FileNotFoundError: Received a file path that doesn't exist. + :raises NotImplementedError: Received an input value that isn't supported yet. + :raises TypeError: Received an object type that isn't supported. + :raises ValueError: Received an input value that isn't supported. + """ + doc = GISDocument() + + for basemap_obj in _basemaps[basemap]: + doc.add_raster_layer(basemap_obj.url, name=basemap_obj.name) + + _add_layer(doc=doc, data=data, name=layer_name) + + # TODO: Zoom to layer. Currently not exposed in Python API. + + doc.sidecar(title="JupyterGIS explorer") + + # TODO: should we return `doc`? It enables the exploration environment more usable, + # but by default, `explore(...)` would display a widget in the notebook _and_ open a + # sidecar for the same widget. The user would need to append a semicolon to disable + # that behavior. We can't disable that behavior from within this function to the + # best of my knowlwedge. + + +def _add_layer( + *, + doc: GISDocument, + data: Any, + name: str, +) -> str: + """Add a layer to the document, autodetecting its type. + + This method currently supports only GeoDataFrames and GeoJSON files. + + :param doc: A GISDocument to add the layer to. + :param data: A data object. Valid data objects include geopandas GeoDataFrames and paths to GeoJSON files. + :param name: The name that will be used for the layer. + + :return: A layer ID string. + + :raises FileNotFoundError: Received a file path that doesn't exist. + :raises NotImplementedError: Received an input value that isn't supported yet. + :raises TypeError: Received an object type that isn't supported. + :raises ValueError: Received an input value that isn't supported. + """ + if isinstance(data, str): + if re.match(r"^(http|https)://", data) is not None: + raise NotImplementedError("URLs not yet supported.") + else: + data = Path(data) + + if isinstance(data, Path): + if not data.exists(): + raise FileNotFoundError(f"File not found: {data}") + + ext = data.suffix.lower() + + if ext in [".geojson", ".json"]: + return doc.add_geojson_layer(path=data, name=name) + elif ext in [".tif", ".tiff"]: + raise NotImplementedError("GeoTIFFs not yet supported.") + else: + raise ValueError(f"Unsupported file type: {data}") + + try: + from geopandas import GeoDataFrame + + if isinstance(data, GeoDataFrame): + return doc.add_geojson_layer(data=data.to_geo_dict(), name=name) + except ImportError: + pass + + raise TypeError(f"Unsupported input type: {type(data)}") diff --git a/python/jupytergis_lab/jupytergis_lab/notebook/gis_document.py b/python/jupytergis_lab/jupytergis_lab/notebook/gis_document.py index b1441ff9a..96d4c7696 100644 --- a/python/jupytergis_lab/jupytergis_lab/notebook/gis_document.py +++ b/python/jupytergis_lab/jupytergis_lab/notebook/gis_document.py @@ -8,6 +8,7 @@ from pycrdt import Array, Map from pydantic import BaseModel +from sidecar import Sidecar from ypywidgets.comm import CommWidget from .objects import ( @@ -42,11 +43,9 @@ class GISDocument(CommWidget): """ Create a new GISDocument object. - :param path: the path to the file that you would like to open. If not provided, a new empty document will be created. + :param path: the path to the file that you would like to open. If not provided, a new ephemeral widget will be created. """ - path: Optional[Path] - def __init__( self, path: Optional[str | Path] = None, @@ -58,15 +57,14 @@ def __init__( pitch: Optional[float] = None, projection: Optional[str] = None, ): - if isinstance(path, str): - path = Path(path) - - self.path = path - - comm_metadata = GISDocument._path_to_comm(str(self.path) if self.path else None) + if isinstance(path, Path): + path = str(path) super().__init__( - comm_metadata=dict(ymodel_name="@jupytergis:widget", **comm_metadata), + comm_metadata={ + "ymodel_name": "@jupytergis:widget", + **self._make_comm(path=path), + } ) self.ydoc["layers"] = self._layers = Map() @@ -105,23 +103,29 @@ def layer_tree(self) -> List[str | Dict]: """ return self._layerTree.to_py() - def save_as(self, path: str | Path) -> None: - """Save the document at a new path.""" - if isinstance(path, str): - path = Path(path) - - if path.name.lower().endswith(".qgz"): - _export_to_qgis(path) - self.path = path - return - - if not path.name.lower().endswith(".jgis"): - path = Path(str(path) + ".jGIS") + def sidecar( + self, + *, + title: str = "JupyterGIS sidecar", + anchor: Literal[ + "split-right", + "split-left", + "split-top", + "split-bottom", + "tab-before", + "tab-after", + "right", + ] = "split-right", + ): + """Open the document in a new sidecar panel. - path.write_text(json.dumps(self.to_py())) - self.path = path + :param anchor: Where to position the new sidecar panel. + """ + sidecar = Sidecar(title=title, anchor=anchor) + with sidecar: + display(self) - def _export_to_qgis(self, path: str | Path) -> bool: + def export_to_qgis(self, path: str | Path) -> bool: # Lazy import, jupytergis_qgis of qgis may not be installed from jupytergis_qgis.qgis_loader import export_project_to_qgis @@ -737,7 +741,7 @@ def _add_source(self, new_object: "JGISObject", id: str | None = None) -> str: self._sources[_id] = obj_dict return _id - def _add_layer(self, new_object: "JGISObject"): + def _add_layer(self, new_object: "JGISObject") -> str: _id = str(uuid4()) obj_dict = json.loads(new_object.json()) self._layers[_id] = obj_dict @@ -745,13 +749,11 @@ def _add_layer(self, new_object: "JGISObject"): return _id @classmethod - def _path_to_comm(cls, filePath: Optional[str]) -> Dict: - path = None + def _make_comm(cls, *, path: Optional[str]) -> Dict: format = None contentType = None - if filePath is not None: - path = filePath + if path is not None: file_name = Path(path).name try: ext = file_name.split(".")[1].lower() @@ -769,8 +771,12 @@ def _path_to_comm(cls, filePath: Optional[str]) -> Dict: contentType = "QGS" else: raise ValueError("File extension is not supported!") + return dict( - path=path, format=format, contentType=contentType, create_ydoc=path is None + path=path, + format=format, + contentType=contentType, + create_ydoc=path is None, ) def to_py(self) -> dict: diff --git a/python/jupytergis_lab/jupytergis_lab/notebook/tests/test_api.py b/python/jupytergis_lab/jupytergis_lab/notebook/tests/test_api.py index 8694d4af9..9cfdc17c9 100644 --- a/python/jupytergis_lab/jupytergis_lab/notebook/tests/test_api.py +++ b/python/jupytergis_lab/jupytergis_lab/notebook/tests/test_api.py @@ -44,14 +44,3 @@ def test_add_and_remove_layer_and_source(self): def test_remove_nonexistent_layer_raises(self): with pytest.raises(KeyError): self.doc.remove_layer("foo") - - -def test_save_as(tmp_path): - os.chdir(tmp_path) - - doc = GISDocument() - assert not list(tmp_path.iterdir()) - - fn = "test.jgis" - doc.save_as(fn) - assert (tmp_path / fn).is_file() diff --git a/python/jupytergis_lab/pyproject.toml b/python/jupytergis_lab/pyproject.toml index 873742e75..6d4c82a45 100644 --- a/python/jupytergis_lab/pyproject.toml +++ b/python/jupytergis_lab/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "comm>=0.1.2,<0.2.0", "pydantic>=2,<3", "jupytergis_core>=0.1.0,<1", + "sidecar>=0.7.0", ] dynamic = ["version", "description", "authors", "urls", "keywords"] license = {file = "LICENSE"} diff --git a/python/jupytergis_lab/style/base.css b/python/jupytergis_lab/style/base.css index 04afd079a..4cdc7612c 100644 --- a/python/jupytergis_lab/style/base.css +++ b/python/jupytergis_lab/style/base.css @@ -419,6 +419,12 @@ div.jGIS-toolbar-widget > div.jp-Toolbar-item:last-child { } /* Support output views, created with "Create new view for cell output" context menu */ +.jp-LinkedOutputView .jp-OutputArea-child:only-child { + /* NOTE: This will eventually be supported directly in JupyterLab + * (https://github.com/jupyterlab/jupyterlab/pull/17487), but we should keep + * it around for a while for backwards compatibility. */ + height: 100%; +} .jp-LinkedOutputView .jupytergis-notebook-widget { height: 100%; } diff --git a/python/jupytergis_lite/jupytergis/__init__.py b/python/jupytergis_lite/jupytergis/__init__.py index 3097075a4..139b9e470 100644 --- a/python/jupytergis_lite/jupytergis/__init__.py +++ b/python/jupytergis_lite/jupytergis/__init__.py @@ -1,3 +1,3 @@ __version__ = "0.4.4" -from jupytergis_lab import GISDocument # noqa +from jupytergis_lab import GISDocument, explore # noqa