Skip to content

Commit e67fb8a

Browse files
authored
Store trait sequences as tuples, not lists (#620)
Ref #592 (comment) This should be easier to follow for users. Mutating a list trait doesn't propagate any updates to the frontend. Rather, you must set a new object. Storing tuple traits instead of list traits means that it's impossible for the user to accidentally not sync those updates to the frontend.
1 parent b854cae commit e67fb8a

File tree

14 files changed

+912
-414
lines changed

14 files changed

+912
-414
lines changed

lonboard/_layer.py

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
ColorAccessor,
4444
FloatAccessor,
4545
NormalAccessor,
46+
VariableLengthTuple,
4647
)
4748

4849
if TYPE_CHECKING:
@@ -105,24 +106,14 @@ def __init__(self, *, extensions: Sequence[BaseExtension] = (), **kwargs):
105106

106107
# TODO: validate that only one extension per type is included. E.g. you can't have
107108
# two data filter extensions.
108-
extensions = traitlets.List(trait=traitlets.Instance(BaseExtension)).tag(
109+
extensions = VariableLengthTuple(traitlets.Instance(BaseExtension)).tag(
109110
sync=True, **ipywidgets.widget_serialization
110111
)
111112
"""
112113
A list of [layer extension](https://developmentseed.org/lonboard/latest/api/layer-extensions/)
113114
objects to add additional features to a layer.
114115
"""
115116

116-
# TODO: the extensions list is not observed; separately, the list object itself does
117-
# not propagate events, so an append wouldn't work.
118-
119-
# @traitlets.observe("extensions")
120-
# def _observe_extensions(self, change):
121-
# """When a new extension is assigned, add its layer props to this layer."""
122-
# new_extensions: List[BaseExtension] = change["new"]
123-
# for extension in new_extensions:
124-
# self.add_traits(**extension._layer_traits)
125-
126117
def _add_extension_traits(self, extensions: Sequence[BaseExtension]):
127118
"""Assign selected traits from the extension onto this Layer."""
128119
for extension in extensions:
@@ -146,6 +137,56 @@ def _add_extension_traits(self, extensions: Sequence[BaseExtension]):
146137
if trait.get_metadata("sync"):
147138
self.keys.append(name)
148139

140+
# This doesn't currently work due to I think some race conditions around syncing
141+
# traits vs the other parameters.
142+
143+
# def add_extension(self, extension: BaseExtension, **props):
144+
# """Add a new layer extension to an existing layer instance.
145+
146+
# Any properties for the added extension should also be passed as keyword
147+
# arguments to this function.
148+
149+
# Examples:
150+
151+
# ```py
152+
# from lonboard import ScatterplotLayer
153+
# from lonboard.layer_extension import DataFilterExtension
154+
155+
# gdf = geopandas.GeoDataFrame(...)
156+
# layer = ScatterplotLayer.from_geopandas(gdf)
157+
158+
# extension = DataFilterExtension(filter_size=1)
159+
# filter_values = gdf["filter_column"]
160+
161+
# layer.add_extension(
162+
# extension,
163+
# get_filter_value=filter_values,
164+
# filter_range=[0, 1]
165+
# )
166+
# ```
167+
168+
# Args:
169+
# extension: The new extension to add.
170+
171+
# Raises:
172+
# ValueError: if another extension of the same type already exists on the
173+
# layer.
174+
# """
175+
# if any(isinstance(extension, type(ext)) for ext in self.extensions):
176+
# raise ValueError("Only one extension of each type permitted")
177+
178+
# with self.hold_trait_notifications():
179+
# self._add_extension_traits([extension])
180+
# self.extensions += (extension,)
181+
182+
# # Assign any extension properties
183+
# added_names: List[str] = []
184+
# for prop_name, prop_value in props.items():
185+
# self.set_trait(prop_name, prop_value)
186+
# added_names.append(prop_name)
187+
188+
# self.send_state(added_names + ["extensions"])
189+
149190
pickable = traitlets.Bool(True).tag(sync=True)
150191
"""
151192
Whether the layer responds to mouse pointer picking events.
@@ -423,9 +464,9 @@ def __init__(self, **kwargs: BitmapLayerKwargs):
423464

424465
bounds = traitlets.Union(
425466
[
426-
traitlets.List(traitlets.Float(), minlen=4, maxlen=4),
427-
traitlets.List(
428-
traitlets.List(traitlets.Float(), minlen=2, maxlen=2),
467+
VariableLengthTuple(traitlets.Float(), minlen=4, maxlen=4),
468+
VariableLengthTuple(
469+
VariableLengthTuple(traitlets.Float(), minlen=2, maxlen=2),
429470
minlen=4,
430471
maxlen=4,
431472
),
@@ -447,7 +488,7 @@ def __init__(self, **kwargs: BitmapLayerKwargs):
447488
- Default: `0`
448489
"""
449490

450-
transparent_color = traitlets.List(
491+
transparent_color = VariableLengthTuple(
451492
traitlets.Float(), default_value=None, allow_none=True, minlen=3, maxlen=4
452493
)
453494
"""The color to use for transparent pixels, in `[r, g, b, a]`.
@@ -456,7 +497,7 @@ def __init__(self, **kwargs: BitmapLayerKwargs):
456497
- Default: `[0, 0, 0, 0]`
457498
"""
458499

459-
tint_color = traitlets.List(
500+
tint_color = VariableLengthTuple(
460501
traitlets.Float(), default_value=None, allow_none=True, minlen=3, maxlen=4
461502
)
462503
"""The color to tint the bitmap by, in `[r, g, b]`.
@@ -519,7 +560,7 @@ def __init__(self, **kwargs: BitmapTileLayerKwargs):
519560
_layer_type = traitlets.Unicode("bitmap-tile").tag(sync=True)
520561

521562
data = traitlets.Union(
522-
[traitlets.Unicode(), traitlets.List(traitlets.Unicode(), minlen=1)]
563+
[traitlets.Unicode(), VariableLengthTuple(traitlets.Unicode(), minlen=1)]
523564
).tag(sync=True)
524565
"""
525566
Either a URL template or an array of URL templates from which the tile data should
@@ -574,7 +615,7 @@ def __init__(self, **kwargs: BitmapTileLayerKwargs):
574615
- Default: `None`
575616
"""
576617

577-
extent = traitlets.List(
618+
extent = VariableLengthTuple(
578619
traitlets.Float(), minlen=4, maxlen=4, allow_none=True, default_value=None
579620
).tag(sync=True)
580621
"""
@@ -657,7 +698,7 @@ def __init__(self, **kwargs: BitmapTileLayerKwargs):
657698
- Default: `0`
658699
"""
659700

660-
transparent_color = traitlets.List(
701+
transparent_color = VariableLengthTuple(
661702
traitlets.Float(), default_value=None, allow_none=True, minlen=3, maxlen=4
662703
)
663704
"""The color to use for transparent pixels, in `[r, g, b, a]`.
@@ -666,7 +707,7 @@ def __init__(self, **kwargs: BitmapTileLayerKwargs):
666707
- Default: `[0, 0, 0, 0]`
667708
"""
668709

669-
tint_color = traitlets.List(
710+
tint_color = VariableLengthTuple(
670711
traitlets.Float(), default_value=None, allow_none=True, minlen=3, maxlen=4
671712
)
672713
"""The color to tint the bitmap by, in `[r, g, b]`.
@@ -2016,7 +2057,7 @@ def from_duckdb(
20162057
- Default: `0.05`
20172058
"""
20182059

2019-
color_domain = traitlets.List(
2060+
color_domain = VariableLengthTuple(
20202061
traitlets.Float(), default_value=None, allow_none=True, minlen=2, maxlen=2
20212062
).tag(sync=True)
20222063
# """

lonboard/_map.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
from lonboard._layer import BaseLayer
1515
from lonboard._viewport import compute_view
1616
from lonboard.basemap import CartoBasemap
17-
from lonboard.traits import DEFAULT_INITIAL_VIEW_STATE, BasemapUrl, ViewStateTrait
17+
from lonboard.traits import (
18+
DEFAULT_INITIAL_VIEW_STATE,
19+
BasemapUrl,
20+
VariableLengthTuple,
21+
ViewStateTrait,
22+
)
1823
from lonboard.types.map import MapKwargs
1924

2025
if TYPE_CHECKING:
@@ -131,7 +136,7 @@ def __init__(
131136
This API is not yet stabilized and may change in the future.
132137
"""
133138

134-
layers = traitlets.List(trait=traitlets.Instance(BaseLayer)).tag(
139+
layers = VariableLengthTuple(traitlets.Instance(BaseLayer)).tag(
135140
sync=True, **ipywidgets.widget_serialization
136141
)
137142
"""One or more `Layer` objects to display on this map.
@@ -170,7 +175,7 @@ def __init__(
170175
custom_attribution = traitlets.Union(
171176
[
172177
traitlets.Unicode(allow_none=True),
173-
traitlets.List(traitlets.Unicode(allow_none=False)),
178+
VariableLengthTuple(traitlets.Unicode(allow_none=False)),
174179
]
175180
).tag(sync=True)
176181
"""
@@ -306,6 +311,66 @@ def __init__(
306311
global `parameters` when that layer is rendered.
307312
"""
308313

314+
def add_layer(
315+
self,
316+
layers: BaseLayer | Sequence[BaseLayer] | Map,
317+
*,
318+
focus: bool = False,
319+
reset_zoom: bool = False,
320+
):
321+
"""Add one or more new layers to the map.
322+
323+
Examples:
324+
325+
```py
326+
from lonboard import viz
327+
328+
m = viz(some_data)
329+
m.add_layer(viz(more_data), focus=True)
330+
```
331+
332+
Args:
333+
layers: New layers to add to the map. This can be:
334+
- a layer instance
335+
- a list or tuple of layer instances
336+
- another `Map` instance, in which case its layers will be added to this
337+
map. This lets you pass the result of `viz` into this method.
338+
339+
focus: If True, set the view state of the map based on the _newly-added_
340+
layers. Defaults to False.
341+
reset_zoom: If True, set the view state of the map based on _all_ layers.
342+
Defaults to False.
343+
344+
Raises:
345+
ValueError: _description_
346+
"""
347+
348+
if focus and reset_zoom:
349+
raise ValueError("focus and reset_zoom may not both be set.")
350+
351+
if isinstance(layers, Map):
352+
new_layers = layers.layers
353+
self.layers += layers.layers
354+
# self.layers =x
355+
# layers = layers.layers
356+
elif isinstance(layers, BaseLayer):
357+
new_layers = (layers,)
358+
layers = [layers]
359+
self.layers += (layers,)
360+
else:
361+
new_layers = tuple(layers)
362+
self.layers += tuple(layers)
363+
364+
self.layers += new_layers
365+
366+
# self.layers += tuple(layers)
367+
368+
if focus:
369+
self.view_state = compute_view(new_layers) # type: ignore
370+
371+
elif reset_zoom:
372+
self.view_state = compute_view(self.layers) # type: ignore
373+
309374
def set_view_state(
310375
self,
311376
*,
@@ -482,4 +547,4 @@ def as_html(self) -> HTML:
482547

483548
@traitlets.default("view_state")
484549
def _default_initial_view_state(self):
485-
return compute_view(self.layers)
550+
return compute_view(self.layers) # type: ignore

lonboard/_viewport.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88
from __future__ import annotations
99

1010
import math
11-
from typing import List, Tuple
11+
from typing import Sequence, Tuple
1212

1313
from lonboard._geoarrow.ops.bbox import Bbox
1414
from lonboard._geoarrow.ops.centroid import WeightedCentroid
1515
from lonboard._layer import BaseLayer
1616

1717

18-
def get_bbox_center(layers: List[BaseLayer]) -> Tuple[Bbox, WeightedCentroid]:
18+
def get_bbox_center(layers: Sequence[BaseLayer]) -> Tuple[Bbox, WeightedCentroid]:
1919
"""Get the bounding box and geometric (weighted) center of the geometries in the
2020
table."""
2121

@@ -55,7 +55,7 @@ def bbox_to_zoom_level(bbox: Bbox) -> int:
5555
return zoom_level
5656

5757

58-
def compute_view(layers: List[BaseLayer]):
58+
def compute_view(layers: Sequence[BaseLayer]):
5959
"""Automatically computes a view state for the data passed in."""
6060
bbox, center = get_bbox_center(layers)
6161

lonboard/_viz.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ def viz(
166166
167167
Alternatively, you can pass a `list` or `tuple` of any of the above inputs.
168168
169+
If you want to easily add more data, to an existing map, you can pass the output of
170+
`viz` into [`Map.add_layer`][lonboard.Map.add_layer].
171+
169172
Args:
170173
data: a data object of any supported type.
171174

lonboard/layer_extension.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
FilterValueAccessor,
77
FloatAccessor,
88
PointAccessor,
9+
VariableLengthTuple,
910
)
1011

1112

@@ -318,9 +319,9 @@ class DataFilterExtension(BaseExtension):
318319
_layer_traits = {
319320
"filter_categories": traitlets.Union(
320321
[
321-
traitlets.List(traitlets.Any()),
322-
traitlets.List(
323-
traitlets.List(traitlets.Any()),
322+
VariableLengthTuple(traitlets.Any()),
323+
VariableLengthTuple(
324+
VariableLengthTuple(traitlets.Any()),
324325
minlen=2,
325326
maxlen=4,
326327
),
@@ -331,9 +332,9 @@ class DataFilterExtension(BaseExtension):
331332
"filter_enabled": traitlets.Bool(True).tag(sync=True),
332333
"filter_range": traitlets.Union(
333334
[
334-
traitlets.List(traitlets.Float(), minlen=2, maxlen=2),
335-
traitlets.List(
336-
traitlets.List(traitlets.Float(), minlen=2, maxlen=2),
335+
VariableLengthTuple(traitlets.Float(), minlen=2, maxlen=2),
336+
VariableLengthTuple(
337+
VariableLengthTuple(traitlets.Float(), minlen=2, maxlen=2),
337338
minlen=2,
338339
maxlen=4,
339340
),
@@ -350,21 +351,21 @@ class DataFilterExtension(BaseExtension):
350351
"get_filter_category": FilterValueAccessor(default_value=None, allow_none=True),
351352
}
352353

353-
filter_size = traitlets.Int(1, min=1, max=4).tag(sync=True)
354+
filter_size = traitlets.Int(None, min=1, max=4, allow_none=True).tag(sync=True)
354355
"""The size of the filter (number of columns to filter by).
355356
356357
The data filter can show/hide data based on 1-4 numeric properties of each object.
357358
358-
- Type: `int`, optional
359+
- Type: `int`. This is required if using range-based filtering.
359360
- Default 1.
360361
"""
361362

362-
category_size = traitlets.Int(1, min=1, max=4).tag(sync=True)
363+
category_size = traitlets.Int(None, min=1, max=4, allow_none=True).tag(sync=True)
363364
"""The size of the category filter (number of columns to filter by).
364365
365366
The category filter can show/hide data based on 1-4 properties of each object.
366367
367-
- Type: `int`, optional
368+
- Type: `int`. This is required if using category-based filtering.
368369
- Default 0.
369370
"""
370371

0 commit comments

Comments
 (0)