Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b3a1554
add layer title trait and set the default value for highlight_color t…
ATL2001 Jul 24, 2025
d9b4d32
add make_toc and make_toc_with_settings functions along with helper f…
ATL2001 Jul 24, 2025
a621dcf
format
ATL2001 Jul 24, 2025
0a6c69a
move title to individual layer types
ATL2001 Jul 27, 2025
c747ef1
lint
ATL2001 Jul 27, 2025
ebb3bd6
renamed toc to `layer_control` moved creation to be a method on the m…
ATL2001 Jul 27, 2025
a55a09f
docstring update
ATL2001 Jul 27, 2025
ebbf70f
docstring update
ATL2001 Jul 27, 2025
5eeb709
format
ATL2001 Jul 27, 2025
63f22f1
Add example notebook for `layer_control`
ATL2001 Jul 27, 2025
167047a
wired up slider for alpha values of colors
ATL2001 Aug 7, 2025
5cce5f6
Merge branch 'table_of_contents' of https://github.com/ATL2001/lonboa…
ATL2001 Aug 7, 2025
f345c60
update layer_control notebook
ATL2001 Aug 7, 2025
b900dc7
ruff
ATL2001 Aug 7, 2025
a741012
Merge branch 'table_of_contents' of https://github.com/ATL2001/lonboa…
ATL2001 Aug 7, 2025
6151737
Merge branch 'main' into table_of_contents
kylebarron Aug 11, 2025
0897f3d
Merge branch 'main' into table_of_contents
kylebarron Aug 11, 2025
d9f6c73
use vendored _to_rgba_no_colorcycle from matplotlib
ATL2001 Aug 11, 2025
bd15c96
make example notebook render less data
ATL2001 Aug 11, 2025
4bd6b2b
limit data in example notebook
ATL2001 Aug 13, 2025
85b1793
move title back to base layer and set highlight color back to None
ATL2001 Aug 13, 2025
28ff53d
remove alpha widget, make title editable, handle any potential trait …
ATL2001 Aug 13, 2025
4b31240
Merge branch 'table_of_contents' of https://github.com/ATL2001/lonboa…
ATL2001 Aug 13, 2025
0db441d
fix layer type
ATL2001 Aug 13, 2025
fed8ac3
Merge branch 'main' into table_of_contents
kylebarron Aug 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion lonboard/_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Copy link
Member

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 None just 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.

Copy link
Collaborator Author

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_widget to make it work with it not being set on the base layer. I'll see what I can do there

Copy link
Collaborator Author

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_color

I'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?

minlen=3,
maxlen=4,
)
Expand Down Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
title = t.CUnicode("Layer", allow_none=False).tag(sync=True)
title = t.Unicode("Layer", allow_none=False).tag(sync=True)

I can't remember but I think the CUnicode trait is legacy from the Python 2/3 transition.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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,
Expand Down
316 changes: 315 additions & 1 deletion lonboard/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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 _to_rgba_no_colorcycle(c, alpha: float | None = None) -> Tuple[float, ...]:



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"))
Copy link
Member

Choose a reason for hiding this comment

The 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
1 change: 1 addition & 0 deletions lonboard/types/layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class BaseLayerKwargs(TypedDict, total=False):
visible: bool
opacity: IntFloat
auto_highlight: bool
title: str


class BitmapLayerKwargs(BaseLayerKwargs, total=False):
Expand Down