-
Notifications
You must be signed in to change notification settings - Fork 54
feat: Table of contents #836
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
b3a1554
d9b4d32
a621dcf
0a6c69a
c747ef1
ebb3bd6
a55a09f
ebbf70f
5eeb709
63f22f1
167047a
5cce5f6
f345c60
b900dc7
a741012
6151737
0897f3d
d9f6c73
bd15c96
4bd6b2b
85b1793
28ff53d
4b31240
0db441d
fed8ac3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
I can't remember but I think the
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it's the casting variant of the Unicode trait, I thought it would be safer in case someone set the layer's title to 1 instead of "1" then it would automatically be changed to "1". I'm happy to change it if you'd like, but that was the reason I used CUnicode |
||||||
| """ | ||||||
| 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, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -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,310 @@ 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 | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We already have a hex -> rgb converter that we vendored from matplotlib:
|
||||
|
|
||||
|
|
||||
| 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")) | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it possible to make the name in the layer selector editable? Then you wouldn't have to choose a new name from Python. |
||||
| 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"<b>{layer.title} Properties</b>") | ||||
| 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 | ||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is unrelated?
There's a question of where defaults should live: in JS code or in Python code. In this case, we don't override the upstream deck.gl default, so leaving this as
Nonejust means "refer to the underlying deck.gl default". I think that might be a better option than copying all the default values into Python code.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did set that intentionally, because it was giving me trouble when it was None, but looking at it right now with eyes from a different day, I think I may be able to change some other stuff in the
_make_color_picker_widgetto make it work with it not being set on the base layer. I'll see what I can do thereThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh I think I may have actually come across a bug in the existing code when I was doing this and thought it was something I was doing. when I try to access the highlight_color property of a layer with None as the default value with
boundary_layer.highlight_colorI'm getting a Trait Error:
TraitError: The 'highlight_color' trait of a PolygonLayer instance must be of length 3 <= L <= 4, but a value of [] was specified.can you re-create that error on your end?