From b3a1554ae87f9068774f7fb7d6a12cf8c1620225 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Wed, 23 Jul 2025 19:41:54 -0500 Subject: [PATCH 01/19] add layer title trait and set the default value for highlight_color to match docstring --- lonboard/_layer.py | 9 ++++++++- lonboard/types/layer.py | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lonboard/_layer.py b/lonboard/_layer.py index a7167fe5..aa745003 100644 --- a/lonboard/_layer.py +++ b/lonboard/_layer.py @@ -247,7 +247,7 @@ def _add_extension_traits(self, extensions: Sequence[BaseExtension]) -> None: highlight_color = VariableLengthTuple( t.Int(), - default_value=None, + default_value=[0, 0, 128, 128], minlen=3, maxlen=4, ) @@ -281,6 +281,13 @@ def _add_extension_traits(self, extensions: Sequence[BaseExtension]) -> None: for an example. """ + title = t.CUnicode("Layer", allow_none=False).tag(sync=True) + """ + The title of the layer. The title of the layer is visible in the table of + contents produced by the lonboard.controls.make_toc() and + lonboard.controls.make_toc_with_settings() functions. + """ + def default_geoarrow_viewport( table: Table, diff --git a/lonboard/types/layer.py b/lonboard/types/layer.py index 59501eeb..1eba683e 100644 --- a/lonboard/types/layer.py +++ b/lonboard/types/layer.py @@ -77,6 +77,7 @@ class BaseLayerKwargs(TypedDict, total=False): visible: bool opacity: IntFloat auto_highlight: bool + title: str class BitmapLayerKwargs(BaseLayerKwargs, total=False): From d9b4d321de7b1afa0182df0589707385e7bd6a3e Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Wed, 23 Jul 2025 19:43:29 -0500 Subject: [PATCH 02/19] add make_toc and make_toc_with_settings functions along with helper functions. --- lonboard/controls.py | 310 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 309 insertions(+), 1 deletion(-) diff --git a/lonboard/controls.py b/lonboard/controls.py index 0a855559..e0b14107 100644 --- a/lonboard/controls.py +++ b/lonboard/controls.py @@ -2,12 +2,19 @@ from functools import partial from typing import Any +import ipywidgets import traitlets from ipywidgets import FloatRangeSlider from ipywidgets.widgets.trait_types import TypedTuple # Import from source to allow mkdocstrings to link to base class -from ipywidgets.widgets.widget_box import VBox +from ipywidgets.widgets.widget_box import HBox, VBox + +from lonboard._layer import BaseLayer +from lonboard.traits import ( + ColorAccessor, + FloatAccessor, +) class MultiRangeSlider(VBox): @@ -88,3 +95,304 @@ def callback(change: dict, *, i: int) -> None: initial_values.append(child.value) super().__init__(children, value=initial_values, **kwargs) + + +def _rgb2hex(r: int, g: int, b: int) -> str: + """Convert an RGB color code values to hex.""" + return f"#{r:02x}{g:02x}{b:02x}" + + +def _hex2rgb(hex_color: str) -> list[int]: + """Convert a hex color code to RGB.""" + hex_color = hex_color.lstrip("#") + rgb_color = [] + for i in (0, 2, 4): + rgb_color.append(int(hex_color[i : i + 2], 16)) + return rgb_color + + +def _link_rgb_and_hex_traits( + rgb_object: Any, + rgb_trait_name: str, + hex_object: Any, + hex_trait_name: str, +) -> None: + """Make links between two objects/traits that hold RBG and hex color codes.""" + + def handle_rgb_color_change(change: traitlets.utils.bunch.Bunch) -> None: + new_color_rgb = change.get("new")[0:3] + new_color_hex = _rgb2hex(*new_color_rgb) + hex_object.set_trait(hex_trait_name, new_color_hex) + + rgb_object.observe(handle_rgb_color_change, rgb_trait_name, "change") + + def handle_hex_color_change(change: traitlets.utils.bunch.Bunch) -> None: + new_color_hex = change.get("new") + new_color_rgb = _hex2rgb(new_color_hex) + rgb_object.set_trait(rgb_trait_name, new_color_rgb) + + hex_object.observe(handle_hex_color_change, hex_trait_name, "change") + + +def _make_visibility_w(layer: BaseLayer) -> ipywidgets.widget: + """Make a widget to control layer visibility.""" + visibility_w = ipywidgets.Checkbox( + value=True, + description="", + disabled=False, + indent=False, + ) + visibility_w.layout = ipywidgets.Layout(width="196px") + ipywidgets.dlink((layer, "title"), (visibility_w, "description")) + ipywidgets.link((layer, "visible"), (visibility_w, "value")) + return visibility_w + + +def _make_toc_item(layer: BaseLayer) -> VBox: + """Return a VBox to be used by a table of contents based on the input layer. + + The VBox will only contain a toggle for the layer's visibility. + """ + visibility_w = _make_visibility_w(layer) + + # with_layer_controls is False return the visibility widget within a VBox + # within a HBox to maintain consistency with the TOC item that would be returned + # if with_layer_controls were True + return VBox([HBox([visibility_w])]) + + +def _make_toc_item_with_settings(layer: BaseLayer) -> VBox: + """Return a VBox to be used by a table of contents based on the input layer. + + The VBox will contain a toggle for the layer's + visibility and a button that when clicked will display widgets linked to the layers + traits so they can be modified. + """ + visibility_w = _make_visibility_w(layer) + + # with_layer_controls is True, make a button that will display the layer props, + # and widgets for the layer properties. Instead of making the trait controlling + # widgets in a random order, make lists so we can make the color widgets at the + # top, followed by the boolean widgets and the number widgets so the layer props + # display has some sort of order + color_widgets, bool_widgets, number_widgets = _make_layer_trait_widgets(layer) + + layer_props_title = ipywidgets.HTML(value=f"{layer.title} Properties") + props_box_layout = ipywidgets.Layout( + border="solid 3px #EEEEEE", + width="240px", + display="none", + ) + props_widgets = [layer_props_title, *color_widgets, *bool_widgets, *number_widgets] + layer_props_box = VBox(props_widgets, layout=props_box_layout) + + props_button = ipywidgets.Button(description="", icon="gear") + props_button.layout.width = "36px" + + def on_props_button_click(_: ipywidgets.widgets.widget_button.Button) -> None: + if layer_props_box.layout.display != "none": + layer_props_box.layout.display = "none" + else: + layer_props_box.layout.display = "flex" + + props_button.on_click(on_props_button_click) + return VBox([HBox([visibility_w, props_button]), layer_props_box]) + + +def _trait_name_to_description(trait_name: str) -> str: + """Make a human readable name from the trait.""" + return trait_name.replace("get_", "").replace("_", " ").title() + + +## style and layout to keep property wigets consistent +prop_style = {"description_width": "initial"} +prop_layout = ipywidgets.Layout(width="224px") + + +def _make_color_picker_widget( + layer: BaseLayer, + trait_name: str, +) -> ipywidgets.widget: + trait_description = _trait_name_to_description(trait_name) + if getattr(layer, trait_name) is not None: + hex_color = _rgb2hex(*getattr(layer, trait_name)) + else: + hex_color = "#000000" + color_picker_w = ipywidgets.ColorPicker( + description=trait_description, + layout=prop_layout, + value=hex_color, + ) + _link_rgb_and_hex_traits(layer, trait_name, color_picker_w, "value") + return color_picker_w + + +def _make_bool_widget( + layer: BaseLayer, + trait_name: str, +) -> ipywidgets.widget: + trait_description = _trait_name_to_description(trait_name) + bool_w = ipywidgets.Checkbox( + value=True, + description=trait_description, + disabled=False, + style=prop_style, + layout=prop_layout, + ) + ipywidgets.link((layer, trait_name), (bool_w, "value")) + return bool_w + + +def _make_float_widget( + layer: BaseLayer, + trait_name: str, + trait: traitlets.TraitType, +) -> ipywidgets.widget: + trait_description = _trait_name_to_description(trait_name) + min_val = None + if hasattr(trait, "min"): + min_val = trait.min + + max_val = None + if hasattr(trait, "max"): + max_val = trait.max + if max_val == float("inf"): + max_val = 999999999999 + + if max_val is not None and max_val is not None: + ## min/max are not None, make a bounded float + float_w = ipywidgets.BoundedFloatText( + value=True, + description=trait_description, + disabled=False, + indent=True, + min=min_val, + max=max_val, + style=prop_style, + layout=prop_layout, + ) + else: + ## min/max are None, use normal flaot, not bounded. + float_w = ipywidgets.FloatText( + value=True, + description=trait_description, + disabled=False, + indent=True, + layout=prop_layout, + ) + ipywidgets.link((layer, trait_name), (float_w, "value")) + return float_w + + +def _make_int_widget( + layer: BaseLayer, + trait_name: str, + trait: traitlets.TraitType, +) -> ipywidgets.widget: + trait_description = _trait_name_to_description(trait_name) + min_val = None + if hasattr(trait, "min"): + min_val = trait.min + + max_val = None + if hasattr(trait, "max"): + max_val = trait.max + if max_val == float("inf"): + max_val = 999999999999 + + if max_val is not None and max_val is not None: + ## min/max are not None, make a bounded int + int_w = ipywidgets.BoundedIntText( + value=True, + description=trait_description, + disabled=False, + indent=True, + min=min_val, + max=max_val, + style=prop_style, + layout=prop_layout, + ) + else: + ## min/max are None, use normal int, not bounded. + int_w = ipywidgets.IntText( + value=True, + description=trait_description, + disabled=False, + indent=True, + style=prop_style, + layout=prop_layout, + ) + ipywidgets.link((layer, trait_name), (int_w, "value")) + return int_w + + +def _make_layer_trait_widgets(layer: BaseLayer) -> tuple[list, list, list]: + color_widgets = [] + bool_widgets = [] + number_widgets = [] + + for trait_name, trait in layer.traits().items(): + ## Guard against making widgets for protected traits + if trait_name.startswith("_"): + continue + # Guard against making widgets for things we've determined we should not + # make widgets to change + if trait_name in ["visible", "selected_index", "title"]: + continue + + if isinstance(trait, ColorAccessor): + color_picker_w = _make_color_picker_widget(layer, trait_name) + color_widgets.append(color_picker_w) + else: + if hasattr(layer, trait_name): + val = getattr(layer, trait_name) + + if val is None: + # do not create a widget for non color traits that are None + # becase we dont have a way to set them back to None + continue + + if isinstance(trait, traitlets.traitlets.Bool): + bool_w = _make_bool_widget(layer, trait_name) + bool_widgets.append(bool_w) + + elif isinstance(trait, (FloatAccessor, traitlets.traitlets.Float)): + float_w = _make_float_widget(layer, trait_name, trait) + number_widgets.append(float_w) + + elif isinstance(trait, (traitlets.traitlets.Int)): + int_w = _make_int_widget(layer, trait_name, trait) + number_widgets.append(int_w) + return (color_widgets, bool_widgets, number_widgets) + +def make_toc(lonboard_map:Any)->VBox: + """Make a simple table of contents (TOC) based on a Lonboard Map. + + The TOC will contain a checkbox for each layer, which controls layer visibility in the Lonboard map. + """ + toc_items = [_make_toc_item(layer) for layer in lonboard_map.layers] + toc = VBox(toc_items) + + ## Observe the map's layers trait, so additions/removals of layers will result in the TOC recreating itself to reflect the map's current state + def handle_layer_change(_:traitlets.utils.bunch.Bunch)->None: + toc_items = [_make_toc_item(layer) for layer in lonboard_map.layers] + toc.children = toc_items + lonboard_map.observe(handle_layer_change, "layers", "change") + return toc + +def make_toc_with_settings(lonboard_map:Any)->VBox: + """Make a table of contents (TOC) based on a Lonboard Map with layer settings. + + The TOC will contain a checkbox for each layer, which controls layer visibility in the Lonboard map. + Each layer in the TOC will also have a settings button, which when clicked will expose properties for the layer which can be changed. + If a layer's property is None when the TOC is created, a widget controling that property will not be created. + """ + toc_items = [_make_toc_item_with_settings(layer) for layer in lonboard_map.layers] + toc = VBox(toc_items) + + ## Observe the map's layers trait, so additions/removals of layers will result in the TOC recreating itself to reflect the map's current state + def handle_layer_change(_:traitlets.utils.bunch.Bunch)->None: + toc_items = [_make_toc_item_with_settings(layer) for layer in lonboard_map.layers] + toc.children = toc_items + lonboard_map.observe(handle_layer_change, "layers", "change") + return toc From a621dcfe026d931ac7e09750e0362c0f27271d39 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Wed, 23 Jul 2025 20:32:28 -0500 Subject: [PATCH 03/19] format --- lonboard/controls.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lonboard/controls.py b/lonboard/controls.py index e0b14107..e749031a 100644 --- a/lonboard/controls.py +++ b/lonboard/controls.py @@ -365,7 +365,8 @@ def _make_layer_trait_widgets(layer: BaseLayer) -> tuple[list, list, list]: number_widgets.append(int_w) return (color_widgets, bool_widgets, number_widgets) -def make_toc(lonboard_map:Any)->VBox: + +def make_toc(lonboard_map: Any) -> VBox: """Make a simple table of contents (TOC) based on a Lonboard Map. The TOC will contain a checkbox for each layer, which controls layer visibility in the Lonboard map. @@ -374,13 +375,15 @@ def make_toc(lonboard_map:Any)->VBox: toc = VBox(toc_items) ## Observe the map's layers trait, so additions/removals of layers will result in the TOC recreating itself to reflect the map's current state - def handle_layer_change(_:traitlets.utils.bunch.Bunch)->None: + def handle_layer_change(_: traitlets.utils.bunch.Bunch) -> None: toc_items = [_make_toc_item(layer) for layer in lonboard_map.layers] toc.children = toc_items + lonboard_map.observe(handle_layer_change, "layers", "change") return toc -def make_toc_with_settings(lonboard_map:Any)->VBox: + +def make_toc_with_settings(lonboard_map: Any) -> VBox: """Make a table of contents (TOC) based on a Lonboard Map with layer settings. The TOC will contain a checkbox for each layer, which controls layer visibility in the Lonboard map. @@ -391,8 +394,11 @@ def make_toc_with_settings(lonboard_map:Any)->VBox: toc = VBox(toc_items) ## Observe the map's layers trait, so additions/removals of layers will result in the TOC recreating itself to reflect the map's current state - def handle_layer_change(_:traitlets.utils.bunch.Bunch)->None: - toc_items = [_make_toc_item_with_settings(layer) for layer in lonboard_map.layers] + def handle_layer_change(_: traitlets.utils.bunch.Bunch) -> None: + toc_items = [ + _make_toc_item_with_settings(layer) for layer in lonboard_map.layers + ] toc.children = toc_items + lonboard_map.observe(handle_layer_change, "layers", "change") return toc From 0a6c69a02e7e16e52d1246d0def10edf82d37403 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sun, 27 Jul 2025 10:22:27 -0500 Subject: [PATCH 04/19] move title to individual layer types --- lonboard/_layer.py | 59 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/lonboard/_layer.py b/lonboard/_layer.py index aa745003..3d0da814 100644 --- a/lonboard/_layer.py +++ b/lonboard/_layer.py @@ -281,13 +281,6 @@ def _add_extension_traits(self, extensions: Sequence[BaseExtension]) -> None: for an example. """ - title = t.CUnicode("Layer", allow_none=False).tag(sync=True) - """ - The title of the layer. The title of the layer is visible in the table of - contents produced by the lonboard.controls.make_toc() and - lonboard.controls.make_toc_with_settings() functions. - """ - def default_geoarrow_viewport( table: Table, @@ -588,6 +581,12 @@ def _weighted_centroid(self) -> WeightedCentroid: # image should represent. return WeightedCentroid(x=center_x, y=center_y, num_items=100) + title = t.CUnicode("BitmapLayer", allow_none=False).tag(sync=True) + """ + The title of the layer. The title of the layer is visible in the layer control + produced by map.layer_control(). + """ + class BitmapTileLayer(BaseLayer): """The BitmapTileLayer renders image tiles (e.g. PNG, JPEG, or WebP) in the web @@ -785,6 +784,12 @@ def __init__(self, **kwargs: BitmapTileLayerKwargs) -> None: - Default: `[255, 255, 255]` """ + title = t.CUnicode("BitmapTileLayer", allow_none=False).tag(sync=True) + """ + The title of the layer. The title of the layer is visible in the layer control + produced by map.layer_control(). + """ + class ColumnLayer(BaseArrowLayer): """The ColumnLayer renders extruded cylinders (tessellated regular polygons) at given @@ -1032,6 +1037,12 @@ def from_duckdb( - Default: `1`. """ + title = t.CUnicode("ColumnLayer", allow_none=False).tag(sync=True) + """ + The title of the layer. The title of the layer is visible in the layer control + produced by map.layer_control(). + """ + class PolygonLayer(BaseArrowLayer): """The `PolygonLayer` renders filled, stroked and/or extruded polygons. @@ -1286,6 +1297,12 @@ def from_duckdb( - Default: `1000`. """ + title = t.CUnicode("PolygonLayer", allow_none=False).tag(sync=True) + """ + The title of the layer. The title of the layer is visible in the layer control + produced by map.layer_control(). + """ + class ScatterplotLayer(BaseArrowLayer): """The `ScatterplotLayer` renders circles at given coordinates. @@ -1524,6 +1541,12 @@ def from_duckdb( - Default: `1`. """ + title = t.CUnicode("ScatterplotLayer", allow_none=False).tag(sync=True) + """ + The title of the layer. The title of the layer is visible in the layer control + produced by map.layer_control(). + """ + class PathLayer(BaseArrowLayer): """The `PathLayer` renders lists of coordinate points as extruded polylines with @@ -1706,6 +1729,12 @@ def from_duckdb( - Default: `1`. """ + title = t.CUnicode("PathLayer", allow_none=False).tag(sync=True) + """ + The title of the layer. The title of the layer is visible in the layer control + produced by map.layer_control(). + """ + class PointCloudLayer(BaseArrowLayer): """The `PointCloudLayer` renders a point cloud with 3D positions, normals and colors. @@ -1823,6 +1852,11 @@ def from_duckdb( - Default: `1`. """ + title = t.CUnicode("PointCloudLayer", allow_none=False).tag(sync=True) + """ + The title of the layer. The title of the layer is visible in the layer control + produced by map.layer_control(). + """ class SolidPolygonLayer(BaseArrowLayer): """The `SolidPolygonLayer` renders filled and/or extruded polygons. @@ -1999,6 +2033,11 @@ def from_duckdb( - Default: `[0, 0, 0, 255]`. """ + title = t.CUnicode("SolidPolygonLayer", allow_none=False).tag(sync=True) + """ + The title of the layer. The title of the layer is visible in the layer control + produced by map.layer_control(). + """ class HeatmapLayer(BaseArrowLayer): """The `HeatmapLayer` visualizes the spatial distribution of data. @@ -2174,3 +2213,9 @@ def from_duckdb( for the object at the same row index. - Default: `1`. """ + + title = t.CUnicode("HeatmapLayer", allow_none=False).tag(sync=True) + """ + The title of the layer. The title of the layer is visible in the layer control + produced by map.layer_control(). + """ From c747ef18049f6efe2147ab1240d2253623587787 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sun, 27 Jul 2025 10:24:36 -0500 Subject: [PATCH 05/19] lint --- lonboard/_layer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lonboard/_layer.py b/lonboard/_layer.py index 3d0da814..d15f9ca4 100644 --- a/lonboard/_layer.py +++ b/lonboard/_layer.py @@ -1858,6 +1858,7 @@ def from_duckdb( produced by map.layer_control(). """ + class SolidPolygonLayer(BaseArrowLayer): """The `SolidPolygonLayer` renders filled and/or extruded polygons. @@ -2039,6 +2040,7 @@ def from_duckdb( produced by map.layer_control(). """ + class HeatmapLayer(BaseArrowLayer): """The `HeatmapLayer` visualizes the spatial distribution of data. From ebb3bd6180290c3fa7342cf4576751c2fba3bd16 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sun, 27 Jul 2025 13:34:32 -0500 Subject: [PATCH 06/19] renamed toc to `layer_control` moved creation to be a method on the map, and protected against RGBA values and implemented handling of properties of arrays as "Custom" things that the layer_control cannot change. --- lonboard/_map.py | 41 +++++++++++++++++++ lonboard/controls.py | 96 +++++++++++++++++++------------------------- 2 files changed, 82 insertions(+), 55 deletions(-) diff --git a/lonboard/_map.py b/lonboard/_map.py index 8a3801b6..17387173 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -9,12 +9,17 @@ import traitlets as t from ipywidgets import CallbackDispatcher from ipywidgets.embed import dependency_state, embed_minimal_html +from ipywidgets.widgets.widget_box import VBox from lonboard._base import BaseAnyWidget from lonboard._environment import DEFAULT_HEIGHT from lonboard._layer import BaseLayer from lonboard._viewport import compute_view from lonboard.basemap import CartoBasemap +from lonboard.controls import ( + _make_layer_control_item, + _make_layer_control_item_with_settings, +) from lonboard.traits import ( DEFAULT_INITIAL_VIEW_STATE, BasemapUrl, @@ -617,3 +622,39 @@ def as_html(self) -> HTML: @traitlets.default("view_state") def _default_initial_view_state(self) -> dict[str, Any]: return compute_view(self.layers) # type: ignore + + def layer_control(self, *, include_settings: bool = False) -> VBox: + """Make layer control box for the layers in the Map. + + Args: + include_settings: if `False` The controler will only contain a checkbox for each layer, which controls layer visibility in the Lonboard map. + If `True` The controller will also have a settings button, which when clicked will expose properties for the layer which can be changed. + If a layer's property is None when the layer is added to the control, a widget for controling that property will not be created. + + !!! note + + For layer properties that are set as an array of values to control the display of each feature separately (example, using a color map to vary the color of the features based on the data) the layer control will display "Custom" instead of allowing the user to change the property. This may change in a future version of Lonboard. + + !!! note + + The layer control uses the [ipywidgets color picker](https://ipywidgets.readthedocs.io/en/latest/examples/Widget%20List.html#color-picker) to set colors. This widget does not respect alpha values, so if you are using an RGBA value to set the color and the alpha of the layer, and then you use the color picker, it will set the alpha value 255. + + Returns: + ipywidgets VBox of the layer control. + + """ + if include_settings is False: + item_creation_function = _make_layer_control_item + else: + item_creation_function = _make_layer_control_item_with_settings + + control_items = [item_creation_function(layer) for layer in self.layers] + contol = VBox(control_items) + + ## Observe the map's layers trait, so additions/removals of layers will result in the control recreating itself to reflect the map's current state + def handle_layer_change(_: traitlets.utils.bunch.Bunch) -> None: + control_items = [item_creation_function(layer) for layer in self.layers] + contol.children = control_items + + self.observe(handle_layer_change, "layers", "change") + return contol diff --git a/lonboard/controls.py b/lonboard/controls.py index e749031a..888ed307 100644 --- a/lonboard/controls.py +++ b/lonboard/controls.py @@ -1,6 +1,7 @@ -from collections.abc import Sequence +from __future__ import annotations + from functools import partial -from typing import Any +from typing import TYPE_CHECKING, Any import ipywidgets import traitlets @@ -10,12 +11,15 @@ # Import from source to allow mkdocstrings to link to base class from ipywidgets.widgets.widget_box import HBox, VBox -from lonboard._layer import BaseLayer from lonboard.traits import ( ColorAccessor, FloatAccessor, ) +if TYPE_CHECKING: + from collections.abc import Sequence + + from lonboard._layer import BaseLayer class MultiRangeSlider(VBox): """A widget for multiple ranged sliders. @@ -97,16 +101,23 @@ def callback(change: dict, *, i: int) -> None: super().__init__(children, value=initial_values, **kwargs) -def _rgb2hex(r: int, g: int, b: int) -> str: - """Convert an RGB color code values to hex.""" - return f"#{r:02x}{g:02x}{b:02x}" +def _rgb2hex(r: int, g: int, b: int, a: int | None = None) -> str: + """Convert an RGB(A) color code values to hex.""" + hex_color = f"#{r:02x}{g:02x}{b:02x}" + if a is not None: + hex_color += f"{a:02x}" + return hex_color def _hex2rgb(hex_color: str) -> list[int]: - """Convert a hex color code to RGB.""" + """Convert a hex color code to RGB(A).""" hex_color = hex_color.lstrip("#") rgb_color = [] - for i in (0, 2, 4): + if len(hex_color) == 6: + hex_range = (0, 2, 4) + if len(hex_color) == 8: + hex_range = (0, 2, 4, 6) + for i in hex_range: rgb_color.append(int(hex_color[i : i + 2], 16)) return rgb_color @@ -148,21 +159,21 @@ def _make_visibility_w(layer: BaseLayer) -> ipywidgets.widget: return visibility_w -def _make_toc_item(layer: BaseLayer) -> VBox: - """Return a VBox to be used by a table of contents based on the input layer. +def _make_layer_control_item(layer: BaseLayer) -> VBox: + """Return a VBox to be used by a layer control based on the input layer. The VBox will only contain a toggle for the layer's visibility. """ visibility_w = _make_visibility_w(layer) # with_layer_controls is False return the visibility widget within a VBox - # within a HBox to maintain consistency with the TOC item that would be returned + # within a HBox to maintain consistency with the layer control item that would be returned # if with_layer_controls were True return VBox([HBox([visibility_w])]) -def _make_toc_item_with_settings(layer: BaseLayer) -> VBox: - """Return a VBox to be used by a table of contents based on the input layer. +def _make_layer_control_item_with_settings(layer: BaseLayer) -> VBox: + """Return a VBox to be used by a layer control based on the input layer. The VBox will contain a toggle for the layer's visibility and a button that when clicked will display widgets linked to the layers @@ -214,10 +225,17 @@ def _make_color_picker_widget( trait_name: str, ) -> ipywidgets.widget: trait_description = _trait_name_to_description(trait_name) - if getattr(layer, trait_name) is not None: - hex_color = _rgb2hex(*getattr(layer, trait_name)) - else: + color_trait_value = getattr(layer, trait_name) + if isinstance(color_trait_value, (list, tuple)) and len(color_trait_value) in [ + 3, + 4, + ]: + # list or tuples of 3/4 are RGB(a) values + hex_color = _rgb2hex(*color_trait_value) + elif color_trait_value is None: hex_color = "#000000" + else: + return ipywidgets.Label(value=f"{trait_description}: Custom") color_picker_w = ipywidgets.ColorPicker( description=trait_description, layout=prop_layout, @@ -249,6 +267,9 @@ def _make_float_widget( trait: traitlets.TraitType, ) -> ipywidgets.widget: trait_description = _trait_name_to_description(trait_name) + if isinstance(getattr(layer, trait_name), float) is False: + # not a single value do not make a control widget + return ipywidgets.Label(value=f"{trait_description}: Custom") min_val = None if hasattr(trait, "min"): min_val = trait.min @@ -272,7 +293,7 @@ def _make_float_widget( layout=prop_layout, ) else: - ## min/max are None, use normal flaot, not bounded. + ## min/max are None, use normal float, not bounded. float_w = ipywidgets.FloatText( value=True, description=trait_description, @@ -290,6 +311,9 @@ def _make_int_widget( trait: traitlets.TraitType, ) -> ipywidgets.widget: trait_description = _trait_name_to_description(trait_name) + if isinstance(getattr(layer, trait_name), int) is False: + # not a single value, do not make a control widget + return ipywidgets.Label(value=f"{trait_description}: Custom") min_val = None if hasattr(trait, "min"): min_val = trait.min @@ -364,41 +388,3 @@ def _make_layer_trait_widgets(layer: BaseLayer) -> tuple[list, list, list]: int_w = _make_int_widget(layer, trait_name, trait) number_widgets.append(int_w) return (color_widgets, bool_widgets, number_widgets) - - -def make_toc(lonboard_map: Any) -> VBox: - """Make a simple table of contents (TOC) based on a Lonboard Map. - - The TOC will contain a checkbox for each layer, which controls layer visibility in the Lonboard map. - """ - toc_items = [_make_toc_item(layer) for layer in lonboard_map.layers] - toc = VBox(toc_items) - - ## Observe the map's layers trait, so additions/removals of layers will result in the TOC recreating itself to reflect the map's current state - def handle_layer_change(_: traitlets.utils.bunch.Bunch) -> None: - toc_items = [_make_toc_item(layer) for layer in lonboard_map.layers] - toc.children = toc_items - - lonboard_map.observe(handle_layer_change, "layers", "change") - return toc - - -def make_toc_with_settings(lonboard_map: Any) -> VBox: - """Make a table of contents (TOC) based on a Lonboard Map with layer settings. - - The TOC will contain a checkbox for each layer, which controls layer visibility in the Lonboard map. - Each layer in the TOC will also have a settings button, which when clicked will expose properties for the layer which can be changed. - If a layer's property is None when the TOC is created, a widget controling that property will not be created. - """ - toc_items = [_make_toc_item_with_settings(layer) for layer in lonboard_map.layers] - toc = VBox(toc_items) - - ## Observe the map's layers trait, so additions/removals of layers will result in the TOC recreating itself to reflect the map's current state - def handle_layer_change(_: traitlets.utils.bunch.Bunch) -> None: - toc_items = [ - _make_toc_item_with_settings(layer) for layer in lonboard_map.layers - ] - toc.children = toc_items - - lonboard_map.observe(handle_layer_change, "layers", "change") - return toc From a55a09fd81df6ec5d71b410a97b53ea417c18be7 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sun, 27 Jul 2025 13:51:32 -0500 Subject: [PATCH 07/19] docstring update --- lonboard/_map.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lonboard/_map.py b/lonboard/_map.py index 17387173..fd95bf09 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -627,8 +627,8 @@ def layer_control(self, *, include_settings: bool = False) -> VBox: """Make layer control box for the layers in the Map. Args: - include_settings: if `False` The controler will only contain a checkbox for each layer, which controls layer visibility in the Lonboard map. - If `True` The controller will also have a settings button, which when clicked will expose properties for the layer which can be changed. + include_settings: If False The controler will only contain a checkbox for each layer, which controls layer visibility in the Lonboard map. + If True The controller will also have a settings button, which when clicked will expose properties for the layer which can be changed. If a layer's property is None when the layer is added to the control, a widget for controling that property will not be created. !!! note From ebbf70f4577dab2061b754f00067b1652e7c0c2d Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sun, 27 Jul 2025 13:55:18 -0500 Subject: [PATCH 08/19] docstring update --- lonboard/_map.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lonboard/_map.py b/lonboard/_map.py index fd95bf09..16071c03 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -627,9 +627,7 @@ def layer_control(self, *, include_settings: bool = False) -> VBox: """Make layer control box for the layers in the Map. Args: - include_settings: If False The controler will only contain a checkbox for each layer, which controls layer visibility in the Lonboard map. - If True The controller will also have a settings button, which when clicked will expose properties for the layer which can be changed. - If a layer's property is None when the layer is added to the control, a widget for controling that property will not be created. + include_settings: If `False` The controler will only contain a checkbox for each layer, which controls layer visibility in the Lonboard map. If `True` The controller will also have a settings button, which when clicked will expose properties for the layer which can be changed. If a layer's property is None when the layer is added to the control, a widget for controling that property will not be created. !!! note From 5eeb70925ae5686c928feb881bbfae91931c8756 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sun, 27 Jul 2025 13:58:05 -0500 Subject: [PATCH 09/19] format --- lonboard/controls.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lonboard/controls.py b/lonboard/controls.py index 888ed307..a73aa18c 100644 --- a/lonboard/controls.py +++ b/lonboard/controls.py @@ -21,6 +21,7 @@ from lonboard._layer import BaseLayer + class MultiRangeSlider(VBox): """A widget for multiple ranged sliders. From 63f22f1f253ae75ebf29f1f867c44fb708e25cfe Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sun, 27 Jul 2025 14:03:12 -0500 Subject: [PATCH 10/19] Add example notebook for `layer_control` --- examples/layer_control.ipynb | 238 +++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 examples/layer_control.ipynb diff --git a/examples/layer_control.ipynb b/examples/layer_control.ipynb new file mode 100644 index 00000000..34de8b60 --- /dev/null +++ b/examples/layer_control.ipynb @@ -0,0 +1,238 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a033bf32-69c9-48bc-a275-8ce1a3901365", + "metadata": {}, + "source": [ + "## Layer Control\n", + "\n", + "This notebook demonstrates the use of the lonbord map's `layer_control`, to control layer visibility and layer properties." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bb983a26-ab11-4070-91bd-c78371ca6d68", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "import geopandas as gpd\n", + "from palettable.colorbrewer.sequential import Blues_8\n", + "\n", + "from lonboard import Map, PathLayer, PolygonLayer\n", + "from lonboard.colormap import apply_continuous_cmap" + ] + }, + { + "cell_type": "markdown", + "id": "3dcbf11b-6c10-46bc-a6de-1d1d37f4e8b6", + "metadata": {}, + "source": [ + "### Get data\n", + "\n", + "Download data from the web and save as geoparquet so we can show some data on our Lonboard map and create a layer control." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "56896a28-4d72-426e-9ea0-2e3854e389f8", + "metadata": {}, + "outputs": [], + "source": [ + "file_urls = [\n", + " (\n", + " \"ne_10m_roads_north_america.parquet\",\n", + " \"https://naciscdn.org/naturalearth/10m/cultural/ne_10m_roads_north_america.zip\",\n", + " ),\n", + " (\n", + " \"geoBoundariesCGAZ_ADM1.parquet\",\n", + " \"https://github.com/wmgeolab/geoBoundaries/raw/main/releaseData/CGAZ/geoBoundariesCGAZ_ADM1.geojson\",\n", + " ),\n", + " (\n", + " \"rivers_asia_37331.parquet\",\n", + " \"https://storage.googleapis.com/fao-maps-catalog-data/geonetwork/aquamaps/rivers_asia_37331.zip\",\n", + " ),\n", + "]\n", + "for filename, url in file_urls:\n", + " if Path(filename).exists() is False:\n", + " print(f\"Reading {filename} from web and saving as geoparquet.\")\n", + " gdf = gpd.read_file(url, engine=\"pyogrio\")\n", + " gdf.to_parquet(filename)\n", + " del gdf\n", + " else:\n", + " print(f\"{filename} already downloaded.\")" + ] + }, + { + "cell_type": "markdown", + "id": "00e57959-6b12-4dc7-8621-6f725d928b96", + "metadata": {}, + "source": [ + "### Read geoparquet files into geopandas dataframes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "230e851e-8bb9-4697-9143-688537c746a0", + "metadata": {}, + "outputs": [], + "source": [ + "boundary_df = gpd.read_parquet(\"geoBoundariesCGAZ_ADM1.parquet\")\n", + "road_df = gpd.read_parquet(\"ne_10m_roads_north_america.parquet\")\n", + "river_df = gpd.read_parquet(\"rivers_asia_37331.parquet\")" + ] + }, + { + "cell_type": "markdown", + "id": "f98bf950-2f66-47eb-8e3a-8123aa2083d5", + "metadata": {}, + "source": [ + "### Create layers\n", + "\n", + "* Create a `PolygonLayer` from the boundary dataframe that is brown with a darker brown outline, that's 1 pixel wide.\n", + "\n", + "* Create a `PathLayer` from the road dataframe with a title and minimum width.\n", + "\n", + "* Create a `PathLayer` from the river dataframe that uses the 'Strahler' column for setting the color and width of the lines, as well as some defaults on the width to make the more prominent rivers darker and wider on the map." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e3366f15-245f-4a8f-b87d-dcb7fd993392", + "metadata": {}, + "outputs": [], + "source": [ + "boundary_layer = PolygonLayer.from_geopandas(\n", + " boundary_df,\n", + " title=\"Boundaries\",\n", + " get_fill_color=[137, 81, 41],\n", + " get_line_color=[102, 60, 31],\n", + " get_line_width=1,\n", + " line_width_units=\"pixels\",\n", + " stroked=True,\n", + ")\n", + "\n", + "road_layer = PathLayer.from_geopandas(road_df, width_min_pixels=0.8)\n", + "\n", + "river_layer = PathLayer.from_geopandas(\n", + " river_df,\n", + " title=\"Rivers\",\n", + " get_color=apply_continuous_cmap(river_df[\"Strahler\"] / 7, Blues_8),\n", + " get_width=river_df[\"Strahler\"],\n", + " width_scale=3000,\n", + " width_min_pixels=0.5,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "9cdd3071-2dd6-4b77-b42d-7def62e3087d", + "metadata": {}, + "source": [ + "### Create the Lonboard `Map` and `layer_control`\n", + "\n", + "Create a lonboard map, and then create a `layer_control` with the `include_settings` parameter to True then display them both.\n", + "\n", + "With `include_settings=True` we will get a layer control that includes the setttings cog, which when expanded will allow us to change some of the layer properties. Note that we did not give this layer a title, so when we make the layer control, the default title will show in the layer control.\n", + "\n", + "If the user unchecks the checkbox next to the layer's name the layer's visibility will be set to False.\n", + "\n", + "!!! note\n", + "\n", + " We're only adding the boundary and road layer at this point, not the river layer. We'll add that later, and when we do we can see the layer control automatically react to the new layer being added to the map, and it will show up in our layer control." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ae4529e4-b656-47eb-8928-a51f02d038fb", + "metadata": {}, + "outputs": [], + "source": [ + "lonboard_map = Map([boundary_layer, road_layer])\n", + "lc = lonboard_map.layer_control(include_settings=True)\n", + "\n", + "display(lonboard_map)\n", + "display(lc)" + ] + }, + { + "cell_type": "markdown", + "id": "9c291004-4d2d-4f7f-9dcb-f3f1d098114f", + "metadata": {}, + "source": [ + "### Change the title of the road layer\n", + "\n", + "By default the title of the layer is the layer's type. When we change the title of the layer, it will automatically be changed in the layer control." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2039d785-3edb-44b8-bbb7-ab61397bdae4", + "metadata": {}, + "outputs": [], + "source": [ + "road_layer.title = \"Roads\"" + ] + }, + { + "cell_type": "markdown", + "id": "d475c61b-e0fd-4227-a3c1-aa2cabeec7d0", + "metadata": {}, + "source": [ + "### Add the River layer\n", + "\n", + "When we add the river layer to the map, the layer control will automatically detect the new layer, and also add it to the layer control.\n", + "\n", + "When we expand the cog for the river layer we will see that the `Color` and the `Width` properties of the layer display `Custom` instead of a color picker/float widget. \n", + "\"Custom\" is displayed in the layer control currently because the layer uses the values from the rows of data to render the lines. This may change in future releases of Lonboard." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13a4bb69-6871-4e40-946f-27edccac9f05", + "metadata": {}, + "outputs": [], + "source": [ + "lonboard_map.add_layer(river_layer, reset_zoom=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a3b72c2-8601-4ae6-8ebd-209a03f19aaf", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "lonboard_toc", + "language": "python", + "name": "lonboard_toc" + }, + "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.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 167047af25d88562ef38e41a793fcf5a57f46dbd Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:34:59 -0500 Subject: [PATCH 11/19] wired up slider for alpha values of colors --- lonboard/controls.py | 61 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/lonboard/controls.py b/lonboard/controls.py index a73aa18c..305cc2eb 100644 --- a/lonboard/controls.py +++ b/lonboard/controls.py @@ -102,23 +102,17 @@ def callback(change: dict, *, i: int) -> None: super().__init__(children, value=initial_values, **kwargs) -def _rgb2hex(r: int, g: int, b: int, a: int | None = None) -> str: - """Convert an RGB(A) color code values to hex.""" +def _rgb2hex(r: int, g: int, b: int) -> str: + """Convert an RGB color code values to hex.""" hex_color = f"#{r:02x}{g:02x}{b:02x}" - if a is not None: - hex_color += f"{a:02x}" return hex_color def _hex2rgb(hex_color: str) -> list[int]: - """Convert a hex color code to RGB(A).""" + """Convert a hex color code to RGB.""" hex_color = hex_color.lstrip("#") rgb_color = [] - if len(hex_color) == 6: - hex_range = (0, 2, 4) - if len(hex_color) == 8: - hex_range = (0, 2, 4, 6) - for i in hex_range: + for i in (0, 2, 4): rgb_color.append(int(hex_color[i : i + 2], 16)) return rgb_color @@ -146,6 +140,32 @@ def handle_hex_color_change(change: traitlets.utils.bunch.Bunch) -> None: hex_object.observe(handle_hex_color_change, hex_trait_name, "change") +def _link_rgba_and_alpha_traits( + rgb_object: Any, + rgb_trait_name: str, + alpha_object: Any, + alpha_trait_name: str, +) -> None: + """Make links between two objects/traits that hold RBGA and an alpha int.""" + + def handle_alpha_color_change(change: traitlets.utils.bunch.Bunch) -> None: + new_alpha = 255 if len(change.get("new")) == 3 else change.get("new")[3] + alpha_object.set_trait(alpha_trait_name, new_alpha) + + rgb_object.observe(handle_alpha_color_change, rgb_trait_name, "change") + + def handle_alpha_change(change: traitlets.utils.bunch.Bunch) -> None: + new_alpha = change.get("new") + rgb_value = getattr(rgb_object, rgb_trait_name).copy() + if len(rgb_value) == 3: + rgb_value.append(new_alpha) + else: + rgb_value[3] = new_alpha + rgb_object.set_trait(rgb_trait_name, rgb_value) + + alpha_object.observe(handle_alpha_change, alpha_trait_name, "change") + + def _make_visibility_w(layer: BaseLayer) -> ipywidgets.widget: """Make a widget to control layer visibility.""" visibility_w = ipywidgets.Checkbox( @@ -224,15 +244,18 @@ def _trait_name_to_description(trait_name: str) -> str: def _make_color_picker_widget( layer: BaseLayer, trait_name: str, -) -> ipywidgets.widget: +) -> VBox: trait_description = _trait_name_to_description(trait_name) color_trait_value = getattr(layer, trait_name) + alpha_val = None if isinstance(color_trait_value, (list, tuple)) and len(color_trait_value) in [ 3, 4, ]: # list or tuples of 3/4 are RGB(a) values - hex_color = _rgb2hex(*color_trait_value) + hex_color = _rgb2hex(*color_trait_value[0:3]) + if len(color_trait_value) == 4: + alpha_val = color_trait_value[3] elif color_trait_value is None: hex_color = "#000000" else: @@ -243,7 +266,19 @@ def _make_color_picker_widget( value=hex_color, ) _link_rgb_and_hex_traits(layer, trait_name, color_picker_w, "value") - return color_picker_w + if alpha_val: + alpha_w = ipywidgets.IntSlider( + value=alpha_val, + min=0, + max=255, + description=trait_description.replace("Color", "Alpha"), + readout=False, + layout=ipywidgets.Layout(width="224px"), + ) + _link_rgba_and_alpha_traits(layer, trait_name, alpha_w, "value") + return VBox([color_picker_w, alpha_w]) + + return VBox([color_picker_w]) def _make_bool_widget( From f345c601225398701fd618363e1c6c7f94d12fcd Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:37:32 -0500 Subject: [PATCH 12/19] update layer_control notebook --- examples/layer_control.ipynb | 476 +++++++++++++++++------------------ 1 file changed, 238 insertions(+), 238 deletions(-) diff --git a/examples/layer_control.ipynb b/examples/layer_control.ipynb index 34de8b60..e50ac653 100644 --- a/examples/layer_control.ipynb +++ b/examples/layer_control.ipynb @@ -1,238 +1,238 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "a033bf32-69c9-48bc-a275-8ce1a3901365", - "metadata": {}, - "source": [ - "## Layer Control\n", - "\n", - "This notebook demonstrates the use of the lonbord map's `layer_control`, to control layer visibility and layer properties." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bb983a26-ab11-4070-91bd-c78371ca6d68", - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "\n", - "import geopandas as gpd\n", - "from palettable.colorbrewer.sequential import Blues_8\n", - "\n", - "from lonboard import Map, PathLayer, PolygonLayer\n", - "from lonboard.colormap import apply_continuous_cmap" - ] - }, - { - "cell_type": "markdown", - "id": "3dcbf11b-6c10-46bc-a6de-1d1d37f4e8b6", - "metadata": {}, - "source": [ - "### Get data\n", - "\n", - "Download data from the web and save as geoparquet so we can show some data on our Lonboard map and create a layer control." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "56896a28-4d72-426e-9ea0-2e3854e389f8", - "metadata": {}, - "outputs": [], - "source": [ - "file_urls = [\n", - " (\n", - " \"ne_10m_roads_north_america.parquet\",\n", - " \"https://naciscdn.org/naturalearth/10m/cultural/ne_10m_roads_north_america.zip\",\n", - " ),\n", - " (\n", - " \"geoBoundariesCGAZ_ADM1.parquet\",\n", - " \"https://github.com/wmgeolab/geoBoundaries/raw/main/releaseData/CGAZ/geoBoundariesCGAZ_ADM1.geojson\",\n", - " ),\n", - " (\n", - " \"rivers_asia_37331.parquet\",\n", - " \"https://storage.googleapis.com/fao-maps-catalog-data/geonetwork/aquamaps/rivers_asia_37331.zip\",\n", - " ),\n", - "]\n", - "for filename, url in file_urls:\n", - " if Path(filename).exists() is False:\n", - " print(f\"Reading {filename} from web and saving as geoparquet.\")\n", - " gdf = gpd.read_file(url, engine=\"pyogrio\")\n", - " gdf.to_parquet(filename)\n", - " del gdf\n", - " else:\n", - " print(f\"{filename} already downloaded.\")" - ] - }, - { - "cell_type": "markdown", - "id": "00e57959-6b12-4dc7-8621-6f725d928b96", - "metadata": {}, - "source": [ - "### Read geoparquet files into geopandas dataframes" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "230e851e-8bb9-4697-9143-688537c746a0", - "metadata": {}, - "outputs": [], - "source": [ - "boundary_df = gpd.read_parquet(\"geoBoundariesCGAZ_ADM1.parquet\")\n", - "road_df = gpd.read_parquet(\"ne_10m_roads_north_america.parquet\")\n", - "river_df = gpd.read_parquet(\"rivers_asia_37331.parquet\")" - ] - }, - { - "cell_type": "markdown", - "id": "f98bf950-2f66-47eb-8e3a-8123aa2083d5", - "metadata": {}, - "source": [ - "### Create layers\n", - "\n", - "* Create a `PolygonLayer` from the boundary dataframe that is brown with a darker brown outline, that's 1 pixel wide.\n", - "\n", - "* Create a `PathLayer` from the road dataframe with a title and minimum width.\n", - "\n", - "* Create a `PathLayer` from the river dataframe that uses the 'Strahler' column for setting the color and width of the lines, as well as some defaults on the width to make the more prominent rivers darker and wider on the map." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e3366f15-245f-4a8f-b87d-dcb7fd993392", - "metadata": {}, - "outputs": [], - "source": [ - "boundary_layer = PolygonLayer.from_geopandas(\n", - " boundary_df,\n", - " title=\"Boundaries\",\n", - " get_fill_color=[137, 81, 41],\n", - " get_line_color=[102, 60, 31],\n", - " get_line_width=1,\n", - " line_width_units=\"pixels\",\n", - " stroked=True,\n", - ")\n", - "\n", - "road_layer = PathLayer.from_geopandas(road_df, width_min_pixels=0.8)\n", - "\n", - "river_layer = PathLayer.from_geopandas(\n", - " river_df,\n", - " title=\"Rivers\",\n", - " get_color=apply_continuous_cmap(river_df[\"Strahler\"] / 7, Blues_8),\n", - " get_width=river_df[\"Strahler\"],\n", - " width_scale=3000,\n", - " width_min_pixels=0.5,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "9cdd3071-2dd6-4b77-b42d-7def62e3087d", - "metadata": {}, - "source": [ - "### Create the Lonboard `Map` and `layer_control`\n", - "\n", - "Create a lonboard map, and then create a `layer_control` with the `include_settings` parameter to True then display them both.\n", - "\n", - "With `include_settings=True` we will get a layer control that includes the setttings cog, which when expanded will allow us to change some of the layer properties. Note that we did not give this layer a title, so when we make the layer control, the default title will show in the layer control.\n", - "\n", - "If the user unchecks the checkbox next to the layer's name the layer's visibility will be set to False.\n", - "\n", - "!!! note\n", - "\n", - " We're only adding the boundary and road layer at this point, not the river layer. We'll add that later, and when we do we can see the layer control automatically react to the new layer being added to the map, and it will show up in our layer control." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ae4529e4-b656-47eb-8928-a51f02d038fb", - "metadata": {}, - "outputs": [], - "source": [ - "lonboard_map = Map([boundary_layer, road_layer])\n", - "lc = lonboard_map.layer_control(include_settings=True)\n", - "\n", - "display(lonboard_map)\n", - "display(lc)" - ] - }, - { - "cell_type": "markdown", - "id": "9c291004-4d2d-4f7f-9dcb-f3f1d098114f", - "metadata": {}, - "source": [ - "### Change the title of the road layer\n", - "\n", - "By default the title of the layer is the layer's type. When we change the title of the layer, it will automatically be changed in the layer control." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2039d785-3edb-44b8-bbb7-ab61397bdae4", - "metadata": {}, - "outputs": [], - "source": [ - "road_layer.title = \"Roads\"" - ] - }, - { - "cell_type": "markdown", - "id": "d475c61b-e0fd-4227-a3c1-aa2cabeec7d0", - "metadata": {}, - "source": [ - "### Add the River layer\n", - "\n", - "When we add the river layer to the map, the layer control will automatically detect the new layer, and also add it to the layer control.\n", - "\n", - "When we expand the cog for the river layer we will see that the `Color` and the `Width` properties of the layer display `Custom` instead of a color picker/float widget. \n", - "\"Custom\" is displayed in the layer control currently because the layer uses the values from the rows of data to render the lines. This may change in future releases of Lonboard." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "13a4bb69-6871-4e40-946f-27edccac9f05", - "metadata": {}, - "outputs": [], - "source": [ - "lonboard_map.add_layer(river_layer, reset_zoom=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0a3b72c2-8601-4ae6-8ebd-209a03f19aaf", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "lonboard_toc", - "language": "python", - "name": "lonboard_toc" - }, - "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.11.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a033bf32-69c9-48bc-a275-8ce1a3901365", + "metadata": {}, + "source": [ + "## Layer Control\n", + "\n", + "This notebook demonstrates the use of the lonbord map's `layer_control`, to control layer visibility and layer properties." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bb983a26-ab11-4070-91bd-c78371ca6d68", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "import geopandas as gpd\n", + "from palettable.colorbrewer.sequential import Blues_8\n", + "\n", + "from lonboard import Map, PathLayer, PolygonLayer\n", + "from lonboard.colormap import apply_continuous_cmap" + ] + }, + { + "cell_type": "markdown", + "id": "3dcbf11b-6c10-46bc-a6de-1d1d37f4e8b6", + "metadata": {}, + "source": [ + "### Get data\n", + "\n", + "Download data from the web and save as geoparquet so we can show some data on our Lonboard map and create a layer control." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "56896a28-4d72-426e-9ea0-2e3854e389f8", + "metadata": {}, + "outputs": [], + "source": [ + "file_urls = [\n", + " (\n", + " \"ne_10m_roads_north_america.parquet\",\n", + " \"https://naciscdn.org/naturalearth/10m/cultural/ne_10m_roads_north_america.zip\",\n", + " ),\n", + " (\n", + " \"geoBoundariesCGAZ_ADM1.parquet\",\n", + " \"https://github.com/wmgeolab/geoBoundaries/raw/main/releaseData/CGAZ/geoBoundariesCGAZ_ADM1.geojson\",\n", + " ),\n", + " (\n", + " \"rivers_asia_37331.parquet\",\n", + " \"https://storage.googleapis.com/fao-maps-catalog-data/geonetwork/aquamaps/rivers_asia_37331.zip\",\n", + " ),\n", + "]\n", + "for filename, url in file_urls:\n", + " if Path(filename).exists() is False:\n", + " print(f\"Reading {filename} from web and saving as geoparquet.\")\n", + " gdf = gpd.read_file(url, engine=\"pyogrio\")\n", + " gdf.to_parquet(filename)\n", + " del gdf\n", + " else:\n", + " print(f\"{filename} already downloaded.\")" + ] + }, + { + "cell_type": "markdown", + "id": "00e57959-6b12-4dc7-8621-6f725d928b96", + "metadata": {}, + "source": [ + "### Read geoparquet files into geopandas dataframes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "230e851e-8bb9-4697-9143-688537c746a0", + "metadata": {}, + "outputs": [], + "source": [ + "boundary_df = gpd.read_parquet(\"geoBoundariesCGAZ_ADM1.parquet\")\n", + "road_df = gpd.read_parquet(\"ne_10m_roads_north_america.parquet\")\n", + "river_df = gpd.read_parquet(\"rivers_asia_37331.parquet\")" + ] + }, + { + "cell_type": "markdown", + "id": "f98bf950-2f66-47eb-8e3a-8123aa2083d5", + "metadata": {}, + "source": [ + "### Create layers\n", + "\n", + "* Create a `PolygonLayer` from the boundary dataframe that is brown with a darker brown outline, that's 1 pixel wide.\n", + "\n", + "* Create a `PathLayer` from the road dataframe with a title and minimum width.\n", + "\n", + "* Create a `PathLayer` from the river dataframe that uses the 'Strahler' column for setting the color and width of the lines, as well as some defaults on the width to make the more prominent rivers darker and wider on the map." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e3366f15-245f-4a8f-b87d-dcb7fd993392", + "metadata": {}, + "outputs": [], + "source": [ + "boundary_layer = PolygonLayer.from_geopandas(\n", + " boundary_df,\n", + " title=\"Boundaries\",\n", + " get_fill_color=[137, 81, 41],\n", + " get_line_color=[102, 60, 31],\n", + " get_line_width=1,\n", + " line_width_units=\"pixels\",\n", + " stroked=True,\n", + ")\n", + "\n", + "road_layer = PathLayer.from_geopandas(road_df, width_min_pixels=0.8)\n", + "\n", + "river_layer = PathLayer.from_geopandas(\n", + " river_df,\n", + " title=\"Rivers\",\n", + " get_color=apply_continuous_cmap(river_df[\"Strahler\"] / 7, Blues_8),\n", + " get_width=river_df[\"Strahler\"],\n", + " width_scale=3000,\n", + " width_min_pixels=0.5,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "9cdd3071-2dd6-4b77-b42d-7def62e3087d", + "metadata": {}, + "source": [ + "### Create the Lonboard `Map` and `layer_control`\n", + "\n", + "Create a lonboard map, and then create a `layer_control` with the `include_settings` parameter to True then display them both.\n", + "\n", + "With `include_settings=True` we will get a layer control that includes the setttings cog, which when expanded will allow us to change some of the layer properties. Note that we did not give this layer a title, so when we make the layer control, the default title will show in the layer control.\n", + "\n", + "If the user unchecks the checkbox next to the layer's name the layer's visibility will be set to False.\n", + "\n", + "!!! note\n", + "\n", + " We're only adding the boundary and road layer at this point, not the river layer. We'll add that later, and when we do we can see the layer control automatically react to the new layer being added to the map, and it will show up in our layer control." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ae4529e4-b656-47eb-8928-a51f02d038fb", + "metadata": {}, + "outputs": [], + "source": [ + "lonboard_map = Map([boundary_layer, road_layer])\n", + "lc = lonboard_map.layer_control(include_settings=True)\n", + "\n", + "display(lonboard_map)\n", + "display(lc)" + ] + }, + { + "cell_type": "markdown", + "id": "9c291004-4d2d-4f7f-9dcb-f3f1d098114f", + "metadata": {}, + "source": [ + "### Change the title of the road layer\n", + "\n", + "By default the title of the layer is the layer's type. When we change the title of the layer, it will automatically be changed in the layer control." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2039d785-3edb-44b8-bbb7-ab61397bdae4", + "metadata": {}, + "outputs": [], + "source": [ + "road_layer.title = \"Roads\"" + ] + }, + { + "cell_type": "markdown", + "id": "d475c61b-e0fd-4227-a3c1-aa2cabeec7d0", + "metadata": {}, + "source": [ + "### Add the River layer\n", + "\n", + "When we add the river layer to the map, the layer control will automatically detect the new layer, and also add it to the layer control.\n", + "\n", + "When we expand the cog for the river layer we will see that the `Color` and the `Width` properties of the layer display `Custom` instead of a color picker/float widget. \n", + "\"Custom\" is displayed in the layer control currently because the layer uses the values from the rows of data to render the lines. This may change in future releases of Lonboard." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13a4bb69-6871-4e40-946f-27edccac9f05", + "metadata": {}, + "outputs": [], + "source": [ + "lonboard_map.add_layer(river_layer, reset_zoom=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a3b72c2-8601-4ae6-8ebd-209a03f19aaf", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "lonboard_toc", + "language": "python", + "name": "lonboard_toc" + }, + "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.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From b900dc73dd1874a637163f989bad4fcbfc9c6a85 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:40:19 -0500 Subject: [PATCH 13/19] ruff --- lonboard/controls.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lonboard/controls.py b/lonboard/controls.py index 305cc2eb..d2af8585 100644 --- a/lonboard/controls.py +++ b/lonboard/controls.py @@ -104,8 +104,7 @@ def callback(change: dict, *, i: int) -> None: def _rgb2hex(r: int, g: int, b: int) -> str: """Convert an RGB color code values to hex.""" - hex_color = f"#{r:02x}{g:02x}{b:02x}" - return hex_color + return f"#{r:02x}{g:02x}{b:02x}" def _hex2rgb(hex_color: str) -> list[int]: From d9f6c7305b377484390e59ac3d343f0bd24c78a2 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:11:56 -0500 Subject: [PATCH 14/19] use vendored _to_rgba_no_colorcycle from matplotlib --- lonboard/controls.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lonboard/controls.py b/lonboard/controls.py index d2af8585..a492f1df 100644 --- a/lonboard/controls.py +++ b/lonboard/controls.py @@ -11,6 +11,7 @@ # Import from source to allow mkdocstrings to link to base class from ipywidgets.widgets.widget_box import HBox, VBox +from lonboard._vendor.matplotlib.colors import _to_rgba_no_colorcycle from lonboard.traits import ( ColorAccessor, FloatAccessor, @@ -109,11 +110,7 @@ def _rgb2hex(r: int, g: int, b: int) -> str: def _hex2rgb(hex_color: str) -> list[int]: """Convert a hex color code to RGB.""" - hex_color = hex_color.lstrip("#") - rgb_color = [] - for i in (0, 2, 4): - rgb_color.append(int(hex_color[i : i + 2], 16)) - return rgb_color + return [int(val*255) for val in _to_rgba_no_colorcycle(hex_color)] def _link_rgb_and_hex_traits( From bd15c96a8553b5e70427777596ad302e72a18c00 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:12:54 -0500 Subject: [PATCH 15/19] make example notebook render less data --- examples/layer_control.ipynb | 481 ++++++++++++++++++----------------- 1 file changed, 243 insertions(+), 238 deletions(-) diff --git a/examples/layer_control.ipynb b/examples/layer_control.ipynb index e50ac653..d1de7dcb 100644 --- a/examples/layer_control.ipynb +++ b/examples/layer_control.ipynb @@ -1,238 +1,243 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "a033bf32-69c9-48bc-a275-8ce1a3901365", - "metadata": {}, - "source": [ - "## Layer Control\n", - "\n", - "This notebook demonstrates the use of the lonbord map's `layer_control`, to control layer visibility and layer properties." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bb983a26-ab11-4070-91bd-c78371ca6d68", - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "\n", - "import geopandas as gpd\n", - "from palettable.colorbrewer.sequential import Blues_8\n", - "\n", - "from lonboard import Map, PathLayer, PolygonLayer\n", - "from lonboard.colormap import apply_continuous_cmap" - ] - }, - { - "cell_type": "markdown", - "id": "3dcbf11b-6c10-46bc-a6de-1d1d37f4e8b6", - "metadata": {}, - "source": [ - "### Get data\n", - "\n", - "Download data from the web and save as geoparquet so we can show some data on our Lonboard map and create a layer control." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "56896a28-4d72-426e-9ea0-2e3854e389f8", - "metadata": {}, - "outputs": [], - "source": [ - "file_urls = [\n", - " (\n", - " \"ne_10m_roads_north_america.parquet\",\n", - " \"https://naciscdn.org/naturalearth/10m/cultural/ne_10m_roads_north_america.zip\",\n", - " ),\n", - " (\n", - " \"geoBoundariesCGAZ_ADM1.parquet\",\n", - " \"https://github.com/wmgeolab/geoBoundaries/raw/main/releaseData/CGAZ/geoBoundariesCGAZ_ADM1.geojson\",\n", - " ),\n", - " (\n", - " \"rivers_asia_37331.parquet\",\n", - " \"https://storage.googleapis.com/fao-maps-catalog-data/geonetwork/aquamaps/rivers_asia_37331.zip\",\n", - " ),\n", - "]\n", - "for filename, url in file_urls:\n", - " if Path(filename).exists() is False:\n", - " print(f\"Reading {filename} from web and saving as geoparquet.\")\n", - " gdf = gpd.read_file(url, engine=\"pyogrio\")\n", - " gdf.to_parquet(filename)\n", - " del gdf\n", - " else:\n", - " print(f\"{filename} already downloaded.\")" - ] - }, - { - "cell_type": "markdown", - "id": "00e57959-6b12-4dc7-8621-6f725d928b96", - "metadata": {}, - "source": [ - "### Read geoparquet files into geopandas dataframes" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "230e851e-8bb9-4697-9143-688537c746a0", - "metadata": {}, - "outputs": [], - "source": [ - "boundary_df = gpd.read_parquet(\"geoBoundariesCGAZ_ADM1.parquet\")\n", - "road_df = gpd.read_parquet(\"ne_10m_roads_north_america.parquet\")\n", - "river_df = gpd.read_parquet(\"rivers_asia_37331.parquet\")" - ] - }, - { - "cell_type": "markdown", - "id": "f98bf950-2f66-47eb-8e3a-8123aa2083d5", - "metadata": {}, - "source": [ - "### Create layers\n", - "\n", - "* Create a `PolygonLayer` from the boundary dataframe that is brown with a darker brown outline, that's 1 pixel wide.\n", - "\n", - "* Create a `PathLayer` from the road dataframe with a title and minimum width.\n", - "\n", - "* Create a `PathLayer` from the river dataframe that uses the 'Strahler' column for setting the color and width of the lines, as well as some defaults on the width to make the more prominent rivers darker and wider on the map." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e3366f15-245f-4a8f-b87d-dcb7fd993392", - "metadata": {}, - "outputs": [], - "source": [ - "boundary_layer = PolygonLayer.from_geopandas(\n", - " boundary_df,\n", - " title=\"Boundaries\",\n", - " get_fill_color=[137, 81, 41],\n", - " get_line_color=[102, 60, 31],\n", - " get_line_width=1,\n", - " line_width_units=\"pixels\",\n", - " stroked=True,\n", - ")\n", - "\n", - "road_layer = PathLayer.from_geopandas(road_df, width_min_pixels=0.8)\n", - "\n", - "river_layer = PathLayer.from_geopandas(\n", - " river_df,\n", - " title=\"Rivers\",\n", - " get_color=apply_continuous_cmap(river_df[\"Strahler\"] / 7, Blues_8),\n", - " get_width=river_df[\"Strahler\"],\n", - " width_scale=3000,\n", - " width_min_pixels=0.5,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "9cdd3071-2dd6-4b77-b42d-7def62e3087d", - "metadata": {}, - "source": [ - "### Create the Lonboard `Map` and `layer_control`\n", - "\n", - "Create a lonboard map, and then create a `layer_control` with the `include_settings` parameter to True then display them both.\n", - "\n", - "With `include_settings=True` we will get a layer control that includes the setttings cog, which when expanded will allow us to change some of the layer properties. Note that we did not give this layer a title, so when we make the layer control, the default title will show in the layer control.\n", - "\n", - "If the user unchecks the checkbox next to the layer's name the layer's visibility will be set to False.\n", - "\n", - "!!! note\n", - "\n", - " We're only adding the boundary and road layer at this point, not the river layer. We'll add that later, and when we do we can see the layer control automatically react to the new layer being added to the map, and it will show up in our layer control." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ae4529e4-b656-47eb-8928-a51f02d038fb", - "metadata": {}, - "outputs": [], - "source": [ - "lonboard_map = Map([boundary_layer, road_layer])\n", - "lc = lonboard_map.layer_control(include_settings=True)\n", - "\n", - "display(lonboard_map)\n", - "display(lc)" - ] - }, - { - "cell_type": "markdown", - "id": "9c291004-4d2d-4f7f-9dcb-f3f1d098114f", - "metadata": {}, - "source": [ - "### Change the title of the road layer\n", - "\n", - "By default the title of the layer is the layer's type. When we change the title of the layer, it will automatically be changed in the layer control." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2039d785-3edb-44b8-bbb7-ab61397bdae4", - "metadata": {}, - "outputs": [], - "source": [ - "road_layer.title = \"Roads\"" - ] - }, - { - "cell_type": "markdown", - "id": "d475c61b-e0fd-4227-a3c1-aa2cabeec7d0", - "metadata": {}, - "source": [ - "### Add the River layer\n", - "\n", - "When we add the river layer to the map, the layer control will automatically detect the new layer, and also add it to the layer control.\n", - "\n", - "When we expand the cog for the river layer we will see that the `Color` and the `Width` properties of the layer display `Custom` instead of a color picker/float widget. \n", - "\"Custom\" is displayed in the layer control currently because the layer uses the values from the rows of data to render the lines. This may change in future releases of Lonboard." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "13a4bb69-6871-4e40-946f-27edccac9f05", - "metadata": {}, - "outputs": [], - "source": [ - "lonboard_map.add_layer(river_layer, reset_zoom=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0a3b72c2-8601-4ae6-8ebd-209a03f19aaf", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "lonboard_toc", - "language": "python", - "name": "lonboard_toc" - }, - "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.11.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a033bf32-69c9-48bc-a275-8ce1a3901365", + "metadata": {}, + "source": [ + "## Layer Control\n", + "\n", + "This notebook demonstrates the use of the lonbord map's `layer_control`, to control layer visibility and layer properties." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bb983a26-ab11-4070-91bd-c78371ca6d68", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "import geopandas as gpd\n", + "from palettable.colorbrewer.sequential import Blues_8\n", + "\n", + "from lonboard import Map, PathLayer, PolygonLayer\n", + "from lonboard.colormap import apply_continuous_cmap" + ] + }, + { + "cell_type": "markdown", + "id": "3dcbf11b-6c10-46bc-a6de-1d1d37f4e8b6", + "metadata": {}, + "source": [ + "### Get data\n", + "\n", + "Download data from the web and save as geoparquet so we can show some data on our Lonboard map and create a layer control." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "56896a28-4d72-426e-9ea0-2e3854e389f8", + "metadata": {}, + "outputs": [], + "source": [ + "file_urls = [\n", + " (\n", + " \"ne_10m_roads_north_america.parquet\",\n", + " \"https://naciscdn.org/naturalearth/10m/cultural/ne_10m_roads_north_america.zip\",\n", + " ),\n", + " (\n", + " \"geoBoundariesCGAZ_ADM1.parquet\",\n", + " \"https://github.com/wmgeolab/geoBoundaries/raw/main/releaseData/CGAZ/geoBoundariesCGAZ_ADM1.geojson\",\n", + " ),\n", + " (\n", + " \"rivers_asia_37331.parquet\",\n", + " \"https://storage.googleapis.com/fao-maps-catalog-data/geonetwork/aquamaps/rivers_asia_37331.zip\",\n", + " ),\n", + "]\n", + "for filename, url in file_urls:\n", + " if Path(filename).exists() is False:\n", + " print(f\"Reading {filename} from web and saving as geoparquet.\")\n", + " gdf = gpd.read_file(url, engine=\"pyogrio\")\n", + " gdf.to_parquet(filename)\n", + " del gdf\n", + " else:\n", + " print(f\"{filename} already downloaded.\")" + ] + }, + { + "cell_type": "markdown", + "id": "00e57959-6b12-4dc7-8621-6f725d928b96", + "metadata": {}, + "source": [ + "### Read geoparquet files into geopandas dataframes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "230e851e-8bb9-4697-9143-688537c746a0", + "metadata": {}, + "outputs": [], + "source": [ + "boundary_df = gpd.read_parquet(\"geoBoundariesCGAZ_ADM1.parquet\")\n", + "boundary_df = boundary_df.cx[-160:-45, 20:90] # parse data down to just some of the north western hemisphere\n", + "\n", + "road_df = gpd.read_parquet(\"ne_10m_roads_north_america.parquet\")\n", + "road_df = road_df.loc[road_df[\"class\"]==\"Interstate\"] # parse data down to just interstates\n", + "\n", + "river_df = gpd.read_parquet(\"rivers_asia_37331.parquet\")\n", + "river_df = river_df.loc[river_df[\"MAJ_NAME\"] == \"Amur\"] # parse data down to just Amur" + ] + }, + { + "cell_type": "markdown", + "id": "f98bf950-2f66-47eb-8e3a-8123aa2083d5", + "metadata": {}, + "source": [ + "### Create layers\n", + "\n", + "* Create a `PolygonLayer` from the boundary dataframe that is brown with a darker brown outline, that's 1 pixel wide.\n", + "\n", + "* Create a `PathLayer` from the road dataframe with a title and minimum width.\n", + "\n", + "* Create a `PathLayer` from the river dataframe that uses the 'Strahler' column for setting the color and width of the lines, as well as some defaults on the width to make the more prominent rivers darker and wider on the map." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e3366f15-245f-4a8f-b87d-dcb7fd993392", + "metadata": {}, + "outputs": [], + "source": [ + "boundary_layer = PolygonLayer.from_geopandas(\n", + " boundary_df,\n", + " title=\"Boundaries\",\n", + " get_fill_color=[137, 81, 41],\n", + " get_line_color=[102, 60, 31],\n", + " get_line_width=1,\n", + " line_width_units=\"pixels\",\n", + " stroked=True,\n", + ")\n", + "\n", + "road_layer = PathLayer.from_geopandas(road_df, width_min_pixels=0.8)\n", + "\n", + "river_layer = PathLayer.from_geopandas(\n", + " river_df,\n", + " title=\"Rivers\",\n", + " get_color=apply_continuous_cmap(river_df[\"Strahler\"] / 7, Blues_8),\n", + " get_width=river_df[\"Strahler\"],\n", + " width_scale=3000,\n", + " width_min_pixels=0.5,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "9cdd3071-2dd6-4b77-b42d-7def62e3087d", + "metadata": {}, + "source": [ + "### Create the Lonboard `Map` and `layer_control`\n", + "\n", + "Create a lonboard map, and then create a `layer_control` with the `include_settings` parameter to True then display them both.\n", + "\n", + "With `include_settings=True` we will get a layer control that includes the setttings cog, which when expanded will allow us to change some of the layer properties. Note that we did not give this layer a title, so when we make the layer control, the default title will show in the layer control.\n", + "\n", + "If the user unchecks the checkbox next to the layer's name the layer's visibility will be set to False.\n", + "\n", + "!!! note\n", + "\n", + " We're only adding the boundary and road layer at this point, not the river layer. We'll add that later, and when we do we can see the layer control automatically react to the new layer being added to the map, and it will show up in our layer control." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ae4529e4-b656-47eb-8928-a51f02d038fb", + "metadata": {}, + "outputs": [], + "source": [ + "lonboard_map = Map([boundary_layer, road_layer])\n", + "lc = lonboard_map.layer_control(include_settings=True)\n", + "\n", + "display(lonboard_map)\n", + "display(lc)" + ] + }, + { + "cell_type": "markdown", + "id": "9c291004-4d2d-4f7f-9dcb-f3f1d098114f", + "metadata": {}, + "source": [ + "### Change the title of the road layer\n", + "\n", + "By default the title of the layer is the layer's type. When we change the title of the layer, it will automatically be changed in the layer control." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2039d785-3edb-44b8-bbb7-ab61397bdae4", + "metadata": {}, + "outputs": [], + "source": [ + "road_layer.title = \"Roads\"" + ] + }, + { + "cell_type": "markdown", + "id": "d475c61b-e0fd-4227-a3c1-aa2cabeec7d0", + "metadata": {}, + "source": [ + "### Add the River layer\n", + "\n", + "When we add the river layer to the map, the layer control will automatically detect the new layer, and also add it to the layer control.\n", + "\n", + "When we expand the cog for the river layer we will see that the `Color` and the `Width` properties of the layer display `Custom` instead of a color picker/float widget. \n", + "\"Custom\" is displayed in the layer control currently because the layer uses the values from the rows of data to render the lines. This may change in future releases of Lonboard." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13a4bb69-6871-4e40-946f-27edccac9f05", + "metadata": {}, + "outputs": [], + "source": [ + "lonboard_map.add_layer(river_layer, reset_zoom=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a3b72c2-8601-4ae6-8ebd-209a03f19aaf", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "lonboard_toc", + "language": "python", + "name": "lonboard_toc" + }, + "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.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 4bd6b2b2b7f614095551c60125fbf9937782d960 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:42:33 -0500 Subject: [PATCH 16/19] limit data in example notebook --- examples/layer_control.ipynb | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/examples/layer_control.ipynb b/examples/layer_control.ipynb index d1de7dcb..6d79d090 100644 --- a/examples/layer_control.ipynb +++ b/examples/layer_control.ipynb @@ -83,13 +83,18 @@ "outputs": [], "source": [ "boundary_df = gpd.read_parquet(\"geoBoundariesCGAZ_ADM1.parquet\")\n", - "boundary_df = boundary_df.cx[-160:-45, 20:90] # parse data down to just some of the north western hemisphere\n", + "boundary_df = boundary_df.loc[\n", + " (boundary_df[\"shapeGroup\"] == \"USA\")\n", + " & (~boundary_df[\"shapeName\"].isin([\"Alaska\", \"Hawaii\"]))\n", + "] # parse data down to lower 48 of USA\n", "\n", "road_df = gpd.read_parquet(\"ne_10m_roads_north_america.parquet\")\n", - "road_df = road_df.loc[road_df[\"class\"]==\"Interstate\"] # parse data down to just interstates\n", + "road_df = road_df.loc[\n", + " road_df[\"class\"] == \"Interstate\"\n", + "] # parse data down to just interstates\n", "\n", "river_df = gpd.read_parquet(\"rivers_asia_37331.parquet\")\n", - "river_df = river_df.loc[river_df[\"MAJ_NAME\"] == \"Amur\"] # parse data down to just Amur" + "river_df = river_df.loc[river_df[\"MAJ_NAME\"] == \"Amur\"] # parse data down to just Amur" ] }, { @@ -209,14 +214,6 @@ "source": [ "lonboard_map.add_layer(river_layer, reset_zoom=True)" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0a3b72c2-8601-4ae6-8ebd-209a03f19aaf", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { From 85b1793e52d8cf2105f9375258b5adcb625bd353 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:43:53 -0500 Subject: [PATCH 17/19] move title back to base layer and set highlight color back to None --- lonboard/_layer.py | 64 +++++++--------------------------------------- 1 file changed, 9 insertions(+), 55 deletions(-) diff --git a/lonboard/_layer.py b/lonboard/_layer.py index d15f9ca4..3594edd1 100644 --- a/lonboard/_layer.py +++ b/lonboard/_layer.py @@ -92,6 +92,8 @@ def __init__( extensions: Sequence[BaseExtension] = (), **kwargs: Any, ) -> None: + if self.title is None: + self.title = self._layer_type.title() + " Layer" # We allow layer extensions to dynamically inject properties onto the layer # widgets where the layer is defined. We wish to allow extensions and their # properties to be passed in the layer constructor. _However_, if @@ -247,7 +249,7 @@ def _add_extension_traits(self, extensions: Sequence[BaseExtension]) -> None: highlight_color = VariableLengthTuple( t.Int(), - default_value=[0, 0, 128, 128], + default_value=None, minlen=3, maxlen=4, ) @@ -281,6 +283,12 @@ def _add_extension_traits(self, extensions: Sequence[BaseExtension]) -> None: for an example. """ + title = t.CUnicode(default_value=None, allow_none=True).tag(sync=True) + """ + The title of the layer. The title of the layer is visible in the layer control + produced by map.layer_control(). + """ + def default_geoarrow_viewport( table: Table, @@ -581,12 +589,6 @@ def _weighted_centroid(self) -> WeightedCentroid: # image should represent. return WeightedCentroid(x=center_x, y=center_y, num_items=100) - title = t.CUnicode("BitmapLayer", allow_none=False).tag(sync=True) - """ - The title of the layer. The title of the layer is visible in the layer control - produced by map.layer_control(). - """ - class BitmapTileLayer(BaseLayer): """The BitmapTileLayer renders image tiles (e.g. PNG, JPEG, or WebP) in the web @@ -784,12 +786,6 @@ def __init__(self, **kwargs: BitmapTileLayerKwargs) -> None: - Default: `[255, 255, 255]` """ - title = t.CUnicode("BitmapTileLayer", allow_none=False).tag(sync=True) - """ - The title of the layer. The title of the layer is visible in the layer control - produced by map.layer_control(). - """ - class ColumnLayer(BaseArrowLayer): """The ColumnLayer renders extruded cylinders (tessellated regular polygons) at given @@ -1037,12 +1033,6 @@ def from_duckdb( - Default: `1`. """ - title = t.CUnicode("ColumnLayer", allow_none=False).tag(sync=True) - """ - The title of the layer. The title of the layer is visible in the layer control - produced by map.layer_control(). - """ - class PolygonLayer(BaseArrowLayer): """The `PolygonLayer` renders filled, stroked and/or extruded polygons. @@ -1297,12 +1287,6 @@ def from_duckdb( - Default: `1000`. """ - title = t.CUnicode("PolygonLayer", allow_none=False).tag(sync=True) - """ - The title of the layer. The title of the layer is visible in the layer control - produced by map.layer_control(). - """ - class ScatterplotLayer(BaseArrowLayer): """The `ScatterplotLayer` renders circles at given coordinates. @@ -1541,12 +1525,6 @@ def from_duckdb( - Default: `1`. """ - title = t.CUnicode("ScatterplotLayer", allow_none=False).tag(sync=True) - """ - The title of the layer. The title of the layer is visible in the layer control - produced by map.layer_control(). - """ - class PathLayer(BaseArrowLayer): """The `PathLayer` renders lists of coordinate points as extruded polylines with @@ -1729,12 +1707,6 @@ def from_duckdb( - Default: `1`. """ - title = t.CUnicode("PathLayer", allow_none=False).tag(sync=True) - """ - The title of the layer. The title of the layer is visible in the layer control - produced by map.layer_control(). - """ - class PointCloudLayer(BaseArrowLayer): """The `PointCloudLayer` renders a point cloud with 3D positions, normals and colors. @@ -1852,12 +1824,6 @@ def from_duckdb( - Default: `1`. """ - title = t.CUnicode("PointCloudLayer", allow_none=False).tag(sync=True) - """ - The title of the layer. The title of the layer is visible in the layer control - produced by map.layer_control(). - """ - class SolidPolygonLayer(BaseArrowLayer): """The `SolidPolygonLayer` renders filled and/or extruded polygons. @@ -2034,12 +2000,6 @@ def from_duckdb( - Default: `[0, 0, 0, 255]`. """ - title = t.CUnicode("SolidPolygonLayer", allow_none=False).tag(sync=True) - """ - The title of the layer. The title of the layer is visible in the layer control - produced by map.layer_control(). - """ - class HeatmapLayer(BaseArrowLayer): """The `HeatmapLayer` visualizes the spatial distribution of data. @@ -2215,9 +2175,3 @@ def from_duckdb( for the object at the same row index. - Default: `1`. """ - - title = t.CUnicode("HeatmapLayer", allow_none=False).tag(sync=True) - """ - The title of the layer. The title of the layer is visible in the layer control - produced by map.layer_control(). - """ From 28ff53dba36380a01f0763641686451dcf33c6b8 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:48:38 -0500 Subject: [PATCH 18/19] remove alpha widget, make title editable, handle any potential trait errors --- lonboard/controls.py | 67 +++++++++++--------------------------------- 1 file changed, 16 insertions(+), 51 deletions(-) diff --git a/lonboard/controls.py b/lonboard/controls.py index a492f1df..e3f92f9a 100644 --- a/lonboard/controls.py +++ b/lonboard/controls.py @@ -110,7 +110,7 @@ def _rgb2hex(r: int, g: int, b: int) -> str: def _hex2rgb(hex_color: str) -> list[int]: """Convert a hex color code to RGB.""" - return [int(val*255) for val in _to_rgba_no_colorcycle(hex_color)] + return [int(val * 255) for val in _to_rgba_no_colorcycle(hex_color)] def _link_rgb_and_hex_traits( @@ -136,33 +136,7 @@ def handle_hex_color_change(change: traitlets.utils.bunch.Bunch) -> None: hex_object.observe(handle_hex_color_change, hex_trait_name, "change") -def _link_rgba_and_alpha_traits( - rgb_object: Any, - rgb_trait_name: str, - alpha_object: Any, - alpha_trait_name: str, -) -> None: - """Make links between two objects/traits that hold RBGA and an alpha int.""" - - def handle_alpha_color_change(change: traitlets.utils.bunch.Bunch) -> None: - new_alpha = 255 if len(change.get("new")) == 3 else change.get("new")[3] - alpha_object.set_trait(alpha_trait_name, new_alpha) - - rgb_object.observe(handle_alpha_color_change, rgb_trait_name, "change") - - def handle_alpha_change(change: traitlets.utils.bunch.Bunch) -> None: - new_alpha = change.get("new") - rgb_value = getattr(rgb_object, rgb_trait_name).copy() - if len(rgb_value) == 3: - rgb_value.append(new_alpha) - else: - rgb_value[3] = new_alpha - rgb_object.set_trait(rgb_trait_name, rgb_value) - - alpha_object.observe(handle_alpha_change, alpha_trait_name, "change") - - -def _make_visibility_w(layer: BaseLayer) -> ipywidgets.widget: +def _make_visibility_w(layer: BaseLayer) -> ipywidgets.HBox: """Make a widget to control layer visibility.""" visibility_w = ipywidgets.Checkbox( value=True, @@ -170,10 +144,13 @@ def _make_visibility_w(layer: BaseLayer) -> ipywidgets.widget: disabled=False, indent=False, ) - visibility_w.layout = ipywidgets.Layout(width="196px") - ipywidgets.dlink((layer, "title"), (visibility_w, "description")) + visibility_w.layout = ipywidgets.Layout(width="16px") + layer_name = ipywidgets.Text("Boundaries") + layer_name.layout = ipywidgets.Layout(width="160px") + + ipywidgets.dlink((layer, "title"), (layer_name, "value")) ipywidgets.link((layer, "visible"), (visibility_w, "value")) - return visibility_w + return ipywidgets.HBox([visibility_w, layer_name]) def _make_layer_control_item(layer: BaseLayer) -> VBox: @@ -240,18 +217,15 @@ def _trait_name_to_description(trait_name: str) -> str: def _make_color_picker_widget( layer: BaseLayer, trait_name: str, -) -> VBox: +) -> ipywidgets.widget: trait_description = _trait_name_to_description(trait_name) color_trait_value = getattr(layer, trait_name) - alpha_val = None if isinstance(color_trait_value, (list, tuple)) and len(color_trait_value) in [ 3, 4, ]: # list or tuples of 3/4 are RGB(a) values hex_color = _rgb2hex(*color_trait_value[0:3]) - if len(color_trait_value) == 4: - alpha_val = color_trait_value[3] elif color_trait_value is None: hex_color = "#000000" else: @@ -262,19 +236,7 @@ def _make_color_picker_widget( value=hex_color, ) _link_rgb_and_hex_traits(layer, trait_name, color_picker_w, "value") - if alpha_val: - alpha_w = ipywidgets.IntSlider( - value=alpha_val, - min=0, - max=255, - description=trait_description.replace("Color", "Alpha"), - readout=False, - layout=ipywidgets.Layout(width="224px"), - ) - _link_rgba_and_alpha_traits(layer, trait_name, alpha_w, "value") - return VBox([color_picker_w, alpha_w]) - - return VBox([color_picker_w]) + return color_picker_w def _make_bool_widget( @@ -396,13 +358,16 @@ def _make_layer_trait_widgets(layer: BaseLayer) -> tuple[list, list, list]: if trait_name in ["visible", "selected_index", "title"]: continue + try: + val = getattr(layer, trait_name) + except traitlets.TraitError: + # if accessing the trait causes an error do not make a widget + continue + if isinstance(trait, ColorAccessor): color_picker_w = _make_color_picker_widget(layer, trait_name) color_widgets.append(color_picker_w) else: - if hasattr(layer, trait_name): - val = getattr(layer, trait_name) - if val is None: # do not create a widget for non color traits that are None # becase we dont have a way to set them back to None From 0db441d276ebbcfb766f9ab876bb7d5b919f97b7 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:54:38 -0500 Subject: [PATCH 19/19] fix layer type --- lonboard/_layer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lonboard/_layer.py b/lonboard/_layer.py index 3594edd1..577d3c20 100644 --- a/lonboard/_layer.py +++ b/lonboard/_layer.py @@ -93,7 +93,10 @@ def __init__( **kwargs: Any, ) -> None: if self.title is None: - self.title = self._layer_type.title() + " Layer" + if hasattr(self, "_layer_type"): + self.title = self._layer_type.title() + " Layer" + else: + self.title = "Layer" # We allow layer extensions to dynamically inject properties onto the layer # widgets where the layer is defined. We wish to allow extensions and their # properties to be passed in the layer constructor. _However_, if