Skip to content

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

Open
wants to merge 24 commits into
base: main
Choose a base branch
from

Conversation

ATL2001
Copy link
Contributor

@ATL2001 ATL2001 commented Jul 24, 2025

This isn't yet a fully working solution, but figured it would be good to put it out there to get feedback on what it does so far

I've resurrected the old code I wrote to make the TOC, and fixed it up to work with the new linting rules.

I've added two new public functions to lonboard.controls make_toc and make_toc_with_settings (I'm very open to renaming those if you have better ideas) and a lot of other private helper functions that users won't need.

  • The make_toc function will make a simple table of contents that can control layer visibility

  • The make_toc_with_settings function will make a table of contents which can control the layer visibility, and also has a button, which when pressed, will allow changes to be made to the layer's properties. The layer traits that are None when the TOC is created are not included in the settings because I don't know of a way to make the ipywidgets widgets work with None (I don't believe it's possible, but would welcome being wrong about that)

I've also added a new title trait to BaseLayer so the layer's title can be shown in the TOC which is defaulted to 'layer'

Additionally, I set the default value on BaseLayer.highlight_color to be [0, 0, 128, 128] to match what is in the docstring for the default value. (when the make_toc_with_settings function was trying to access the trait without the default it was throwing an error)

what's not working:

The current implementation blows up when you create a TOC with settings and a layer uses a continuous colormap (like what is in the examples/map_challenge 6-asia notebook) (and I assume the same will happen for a categorical colormap) I haven't thought much about how to handle those situations yet, but if you have any ideas, I'm all ears.

image

@ATL2001 ATL2001 changed the title Table of contents feat: Table of contents Jul 24, 2025
@github-actions github-actions bot added the feat label Jul 24, 2025
@kylebarron
Copy link
Member

This isn't yet a fully working solution, but figured it would be good to put it out there to get feedback on what it does so far

Thanks! I'd like to be more attentive to PRs than before.

I've added two new public functions to lonboard.controls make_toc and make_toc_with_settings (I'm very open to renaming those if you have better ideas) and a lot of other private helper functions that users won't need.

Perhaps we could make this a method on the Map object itself? Like Map.toc()?

It's a code smell to me to have both make_toc and make_toc_with_settings. Can we have an optional, default parameter to make_toc, so the latter is just make_toc(settings=...).

I think we should also brainstorm more on what this should be called. Instead of TOC maybe Layer Widget, Layer Selector, Legend, Layer Control, etc.

Widget and Control are becoming confusing terminology. A lot of JS web maps like Mapbox GL JS called "Control" the extra map elements, like distance/compass. Deck.gl calls those Widgets.

While in Python, ipywidgets-based widgets have the opposite meaning of "widget". In particular, what do we call something appearing on the map itself (from JS) vs something separately in the notebook page that communicates with the map element. We should solidify this terminology.

  • The make_toc function will make a simple table of contents that can control layer visibility
  • The make_toc_with_settings function will make a table of contents which can control the layer visibility, and also has a button, which when pressed, will allow changes to be made to the layer's properties. The layer traits that are None when the TOC is created are not included in the settings because I don't know of a way to make the ipywidgets widgets work with None (I don't believe it's possible, but would welcome being wrong about that)

I've also added a new title trait to BaseLayer so the layer's title can be shown in the TOC which is defaulted to 'layer'

We should decide whether a layer's title is a property of the map or of the layer itself.

In particular, a layer can be rendered in multiple maps. Should the layer always have the same name? It's more flexible to allow each instance of the layer to have a different name. In that case, it wouldn't be an attribute of the layer but instead the Map object would hold essentially a dict of layer instances, keyed by name.

Additionally, we should default the title of a layer to the name of the class. So the default title for a polygon layer would be "PolygonLayer".

Additionally, I set the default value on BaseLayer.highlight_color to be [0, 0, 128, 128] to match what is in the docstring for the default value. (when the make_toc_with_settings function was trying to access the trait without the default it was throwing an error)

See inline note.

what's not working:

The current implementation blows up when you create a TOC with settings and a layer uses a continuous colormap (like what is in the examples/map_challenge 6-asia notebook) (and I assume the same will happen for a categorical colormap) I haven't thought much about how to handle those situations yet, but if you have any ideas, I'm all ears.

Perhaps we should remove color options from this PR and work on that in a follow up PR? I figure that most of the time a user will add some sort of colormap? Or, if a layer is using a custom colormap (anything other than a scalar color), then we can just print "custom" in the legend for that layer.

@ATL2001
Copy link
Contributor Author

ATL2001 commented Jul 25, 2025

In the original code I wrote I had one function and it had a parameter for with_settings, a boolean which allowed the settings to be toggled on or off but ruff complained about it so i just made it into two functions (https://docs.astral.sh/ruff/rules/boolean-type-hint-positional-argument) (I personally don't think I like that linting rule very much😄) I'd be happy to consolidate it back and drop a NOQA FBT001 on it.

I don't have any strong feeling either way on the name of it, I do like layer control, but your idea of why to not use control is valid.

In my experiences making map products and geospatial visuals I've not needed to make one layer and use it in different maps with a different name unless I also needed to change other parts of the layer as well (colors, line widths, popups something like that), so in that case you'd need to make a new layer anyway.

default title, that's a good idea!

I think I like the idea of "custom" or something along those lines for the colors that aren't just one color (I'd rather not throw that part out totally for now, because what I have right now works great for single colors),

I'll try to carve out some time this weekend and see what I can come up with.

@ATL2001
Copy link
Contributor Author

ATL2001 commented Jul 27, 2025

I've got a .layer_control() method on the map itself now, and I believe I've got the color stuff sorted out with adding a label to the settings control that just says "Custom" instead of making a widget to control the value for the traits who's values are array like.

And I realized I wasn't understanding properly what ruff was telling me about the boolean in the function, it just wanted me to make it a non-positional argument, so now there's just one method on the map to make the layer control with an argument for include settings, no rule ignoring needed 😄

@kylebarron
Copy link
Member

but ruff complained about it

Ruff only complains about positional boolean arguments. If you make it a keyword-only argument then ruff won't complain.

I think I like the idea of "custom" or something along those lines for the colors that aren't just one color (I'd rather not throw that part out totally for now, because what I have right now works great for single colors),

👍

@ATL2001
Copy link
Contributor Author

ATL2001 commented Jul 28, 2025

Ruff only complains about positional boolean arguments. If you make it a keyword-only argument then ruff won't complain.

yeah, once I reread it and looked at the example, it made a lot more sense! that's how I've got it now 😎

@kylebarron
Copy link
Member

@ATL2001 did you plan more changes here before asking for my review? Or is this ready for an in-depth review? Should we try to get it in for the next release?

@ATL2001
Copy link
Contributor Author

ATL2001 commented Aug 6, 2025

I actually do want to make one more small tweak to the widget I have linked color accessor (I'd like to add another widget to control the alpha similar to how it works in the panel app demo) I'll see if I can wire it up tonight

@ATL2001
Copy link
Contributor Author

ATL2001 commented Aug 7, 2025

I've still got a kink to work out, it'll likely be the weekend before I can iron it out 😞

@kylebarron
Copy link
Member

kylebarron commented Aug 7, 2025

No rush, we can make another release then. I think I want to release #846 soon, and along with the python 3.9 deprecation it needs a minor release. In contrast to Rust, it's relatively low-downside to making minor releases, so we can make more releases soon in the future.

@ATL2001
Copy link
Contributor Author

ATL2001 commented Aug 7, 2025

ok, ready for review now! 👍

@ATL2001 ATL2001 marked this pull request as ready for review August 7, 2025 21:45
@@ -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
Contributor 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
Contributor 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?

@@ -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
Contributor 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

Comment on lines 105 to 111
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, ...]:

" \"https://naciscdn.org/naturalearth/10m/cultural/ne_10m_roads_north_america.zip\",\n",
" ),\n",
" (\n",
" \"geoBoundariesCGAZ_ADM1.parquet\",\n",
Copy link
Member

Choose a reason for hiding this comment

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

Trying to render the geo boundaries file crashes VSCode for me and doesn't display well in Jupyter Lab. We should find a smaller file to work with, or just use the other two layers.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oh, I'm sorry! I'll do something to limit what gets displayed

@@ -581,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)
Copy link
Member

Choose a reason for hiding this comment

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

I don't love the idea of repeating each layer name another time. Ideally we could use self.cls.__name__ here, but we don't have access to cls from this class attribute. Perhaps it would be better to leave this as None and let the default get set from the JS side.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

that's a good idea, I hadn't considered setting it on the JS side, I'll see if i can do that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I failed miserably on the js side to set it,

I moved the title back to the BaseLayer and then in the init method I set it to check if the title is None, and if so use the _layer_type to give it a default title. if that's not a workable solution I'll need a hand on the JS end :(

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

Choose a reason for hiding this comment

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

The settings button is pretty small, and the drop down isn't expanded by default; is there a reason not to show the settings button all the time?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was thinking that the layer control could be used in some applications where the designer of the app would want the user to be able to set the visibility of the layer, but not have access to control the other properties. In that case they would set include_settings=False. If they wanted the user to be able to change the other properties then they set include_settings=True and then the setting gear is there, so they can access the layer properties.

my reason for having it as a drop down is just to save space and make it less intrusive

Comment on lines 142 to 165
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")
Copy link
Member

Choose a reason for hiding this comment

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

It looks like you're manually creating the RGBA color from the opacity field of the control? We don't need to do that; it's easier to pass directly to the opacity field of the layer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oh wow, you are spot on there!, I spent a lot of time wiring that up, but you're totally correct, it's entirely unnecessary, i'll rip that out 😆

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants