diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 41f7fbef7..a4476291f 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -26,7 +26,7 @@ jobs: channels: conda-forge - name: Mamba install dependencies - run: mamba install python=${{ matrix.python-version }} pip nodejs=16 flake8 jupyterlab ipywidgets>=8.0.1 jupyter-packaging~=0.7.9 + run: mamba install python=${{ matrix.python-version }} pip nodejs=16 flake8 jupyterlab ipywidgets>=8.0.1 jupyter-packaging~=0.7.9 pandas - name: Install ipyleaflet run: pip install . diff --git a/examples/Subitems.ipynb b/examples/Subitems.ipynb new file mode 100644 index 000000000..127e03b45 --- /dev/null +++ b/examples/Subitems.ipynb @@ -0,0 +1,182 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Set up for JupyterLite\n", + "try:\n", + " import piplite\n", + " await piplite.install('ipyleaflet')\n", + "except ImportError:\n", + " pass" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ipyleaflet import Map, Marker, Choropleth, MagnifyingGlass, ColormapControl, AwesomeIcon, basemaps, basemap_to_tiles\n", + "import json\n", + "import pandas as pd\n", + "from ipywidgets import link, FloatSlider\n", + "from branca.colormap import linear\n", + "center = (43, -100)\n", + "zoom = 4" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "geo_json_data = json.load(open(\"us-states.json\"))\n", + "m1 = Map(center=center, zoom=zoom)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "unemployment = pd.read_csv(\"US_Unemployment_Oct2012.csv\")\n", + "unemployment = dict(\n", + " zip(unemployment[\"State\"].tolist(), unemployment[\"Unemployment\"].tolist())\n", + ")\n", + "\n", + "marker1 = Marker(location=(center))\n", + "\n", + "layer1 = Choropleth(\n", + " geo_data=geo_json_data,\n", + " choro_data=unemployment,\n", + " colormap=linear.YlOrRd_04,\n", + " style={\"fillOpacity\": 0.8, \"dashArray\": \"5, 5\"},\n", + " subitems= (marker1,)\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m1.add(layer1)\n", + "m1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m1.remove(layer1)\n", + "m1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m1.add(layer1)\n", + "colormap_control1 = ColormapControl(\n", + " caption='Unemployment rate',\n", + " colormap=layer1.colormap,\n", + " value_min=layer1.value_min,\n", + " value_max=layer1.value_max,\n", + " position='topright',\n", + " transparent_bg=True\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "layer1.subitems = layer1.subitems+(colormap_control1,)\n", + "m1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "marker2 = Marker(location=(center[0]-4, center[1] - 4))\n", + "marker3 = Marker(location=(center[0]-8, center[1] - 8))\n", + "layer2 = basemap_to_tiles(basemaps.Strava.Water, subitems= (marker2,))\n", + "icon1 = AwesomeIcon(\n", + " name='gear',\n", + " marker_color='blue',\n", + " icon_color='darkblue',\n", + " spin=True\n", + " \n", + ")\n", + "marker4 = Marker(icon=icon1, location=(center[0], center[1] - 4))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "layer2.subitems = layer2.subitems+(marker3, marker4)\n", + "m1.add(layer2)\n", + "m1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m1.remove(layer1)\n", + "m1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m1.remove(layer2)" + ] + } + ], + "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.9.12" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/ipyleaflet/leaflet.py b/ipyleaflet/leaflet.py index 0f5deed85..6489f4af4 100644 --- a/ipyleaflet/leaflet.py +++ b/ipyleaflet/leaflet.py @@ -140,6 +140,19 @@ class Layer(Widget, InteractMixin): pane = Unicode('').tag(sync=True) options = List(trait=Unicode()).tag(sync=True) + subitems = Tuple().tag(trait=Instance(Widget), sync=True, **widget_serialization) + + @validate('subitems') + def _validate_subitems(self, proposal): + '''Validate subitems list. + + Makes sure only one instance of any given subitem can exist in the + subitem list. + ''' + subitem_ids = [subitem.model_id for subitem in proposal.value] + if len(set(subitem_ids)) != len(subitem_ids): + raise Exception('duplicate subitem detected, only use each subitem once') + return proposal.value def __init__(self, **kwargs): super(Layer, self).__init__(**kwargs) @@ -2537,6 +2550,7 @@ def add(self, item): if item.model_id in self._control_ids: raise ControlException('control already on map: %r' % item) self.controls = tuple([control for control in self.controls] + [item]) + return self def remove(self, item): @@ -2635,4 +2649,5 @@ async def _fit_bounds(self, bounds): else: self.zoom -= 1 await wait_for_change(self, 'bounds') + break diff --git a/js/src/Map.js b/js/src/Map.js index 4d1f0372a..812cd5ed8 100644 --- a/js/src/Map.js +++ b/js/src/Map.js @@ -1,7 +1,6 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. - const widgets = require('@jupyter-widgets/base'); const L = require('./leaflet.js'); const utils = require('./utils.js'); @@ -158,6 +157,7 @@ LeafletMapModel.serializers = { dragging_style: { deserialize: widgets.unpack_models } }; + export class LeafletMapView extends utils.LeafletDOMWidgetView { initialize(options) { super.initialize(options); @@ -188,12 +188,11 @@ export class LeafletMapView extends utils.LeafletDOMWidgetView { }).then(view => { this.obj.addLayer(view.obj); - // Trigger the displayed event of the child view. this.displayed.then(() => { view.trigger('displayed', this); }); - return view; + }); } @@ -208,11 +207,11 @@ export class LeafletMapView extends utils.LeafletDOMWidgetView { }).then(view => { this.obj.addControl(view.obj); + // Trigger the displayed event of the child view. this.displayed.then(() => { view.trigger('displayed', this); }); - return view; }); } @@ -483,4 +482,4 @@ export class LeafletMapView extends utils.LeafletDOMWidgetView { break; } } -} +} \ No newline at end of file diff --git a/js/src/layers/Layer.js b/js/src/layers/Layer.js index 3a5782afa..2781fdae1 100644 --- a/js/src/layers/Layer.js +++ b/js/src/layers/Layer.js @@ -24,16 +24,20 @@ export class LeafletLayerModel extends widgets.WidgetModel { popup_min_width: 50, popup_max_width: 300, popup_max_height: null, - pane: '' + pane: '', + subitems: [] }; } } LeafletLayerModel.serializers = { ...widgets.WidgetModel.serializers, - popup: { deserialize: widgets.unpack_models } + popup: { deserialize: widgets.unpack_models }, + subitems: { deserialize: widgets.unpack_models } }; + + export class LeafletUILayerModel extends LeafletLayerModel { defaults() { return { @@ -51,6 +55,33 @@ export class LeafletLayerView extends utils.LeafletWidgetView { this.popup_content_promise = Promise.resolve(); } + remove_subitem_view(child_view) { + if(child_view instanceof LeafletLayerView) { + this.map_view.obj.removeLayer(child_view.obj); + } else { + this.map_view.obj.removeControl(child_view.obj); + } + child_view.remove(); + } + + add_subitem_model(child_model) { + return this.create_child_view(child_model, { + map_view: this + }).then(view => { + if (child_model instanceof LeafletLayerModel) { + this.map_view.obj.addLayer(view.obj); + } else { + this.map_view.obj.addControl(view.obj); + } + + //Trigger the displayed event of the child view. + this.displayed.then(() => { + view.trigger('displayed', this); + }); + return view; + }); + } + render() { return Promise.resolve(this.create_obj()).then(() => { this.leaflet_events(); @@ -60,6 +91,12 @@ export class LeafletLayerView extends utils.LeafletWidgetView { this.bind_popup(value); }); this.update_pane(); + this.subitem_views = new widgets.ViewList( + this.add_subitem_model, + this.remove_subitem_view, + this + ); + this.subitem_views.update(this.model.get('subitems')); }); } @@ -128,10 +165,19 @@ export class LeafletLayerView extends utils.LeafletWidgetView { }, this ); + this.listenTo( + this.model, + 'change:subitems', + function () { + this.subitem_views.update(this.subitems); + }, + this + ); } remove() { super.remove(); + this.subitem_views.remove(); this.popup_content_promise.then(() => { if (this.popup_content) { this.popup_content.remove(); diff --git a/js/src/layers/LayerGroup.js b/js/src/layers/LayerGroup.js index 58934956b..54cc11a1b 100644 --- a/js/src/layers/LayerGroup.js +++ b/js/src/layers/LayerGroup.js @@ -17,7 +17,7 @@ export class LeafletLayerGroupModel extends layer.LeafletLayerModel { } LeafletLayerGroupModel.serializers = { - ...widgets.WidgetModel.serializers, + ...layer.LeafletLayerModel.serializers, layers: { deserialize: widgets.unpack_models } }; diff --git a/ui-tests/notebooks/Subitems.ipynb b/ui-tests/notebooks/Subitems.ipynb new file mode 100644 index 000000000..11b0de3bb --- /dev/null +++ b/ui-tests/notebooks/Subitems.ipynb @@ -0,0 +1,98 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "014844f713124f2a873205e61115e65c", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Map(center=[43, -100], controls=(ZoomControl(options=['position', 'zoom_in_text', 'zoom_in_title', 'zoom_out_t…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from ipyleaflet import Map, Marker, Choropleth, ColormapControl, AwesomeIcon, basemaps, basemap_to_tiles\n", + "import json\n", + "import pandas as pd\n", + "from branca.colormap import linear\n", + "center = (43, -100)\n", + "zoom = 4\n", + "\n", + "geo_json_data = json.load(open(\"../../examples/us-states.json\"))\n", + "m1 = Map(center=center, zoom=zoom)\n", + "unemployment = pd.read_csv(\"../../examples/US_Unemployment_Oct2012.csv\")\n", + "unemployment = dict(\n", + " zip(unemployment[\"State\"].tolist(), unemployment[\"Unemployment\"].tolist())\n", + ")\n", + "\n", + "marker1 = Marker(location=(center))\n", + "\n", + "layer1 = Choropleth(\n", + " geo_data=geo_json_data,\n", + " choro_data=unemployment,\n", + " colormap=linear.YlOrRd_04,\n", + " style={\"fillOpacity\": 0.8, \"dashArray\": \"5, 5\"},\n", + " subitems= (marker1,)\n", + ")\n", + "\n", + "colormap_control1 = ColormapControl(\n", + " caption='Unemployment rate',\n", + " colormap=layer1.colormap,\n", + " value_min=layer1.value_min,\n", + " value_max=layer1.value_max,\n", + " position='topright',\n", + " transparent_bg=True\n", + ")\n", + "layer1.subitems = layer1.subitems+(colormap_control1,)\n", + "layer2 = basemap_to_tiles(basemaps.Esri.WorldStreetMap, subitems= ())\n", + "icon1 = AwesomeIcon(\n", + " name='gear',\n", + " marker_color='blue',\n", + " icon_color='darkblue',\n", + " spin=False\n", + " \n", + ")\n", + "marker2 = Marker(icon=icon1, location=(center[0], center[1] - 4))\n", + "layer2.subitems = layer2.subitems+(marker2,)\n", + "m1.add(layer1)\n", + "m1.add(layer2)" + ] + } + ], + "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.9.12" + }, + "vscode": { + "interpreter": { + "hash": "4d7e9831f9b20aa8f128d931cc0311348552e4211606356a22b5faacff22e7b0" + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/ui-tests/tests/ipyleaflet.test.ts-snapshots/dark--Subitems-ipynb-cell-0-linux.png b/ui-tests/tests/ipyleaflet.test.ts-snapshots/dark--Subitems-ipynb-cell-0-linux.png new file mode 100644 index 000000000..b3a1b984e Binary files /dev/null and b/ui-tests/tests/ipyleaflet.test.ts-snapshots/dark--Subitems-ipynb-cell-0-linux.png differ diff --git a/ui-tests/tests/ipyleaflet.test.ts-snapshots/light--Subitems-ipynb-cell-0-linux.png b/ui-tests/tests/ipyleaflet.test.ts-snapshots/light--Subitems-ipynb-cell-0-linux.png new file mode 100644 index 000000000..658b21213 Binary files /dev/null and b/ui-tests/tests/ipyleaflet.test.ts-snapshots/light--Subitems-ipynb-cell-0-linux.png differ