From d37c8ce9a8ce2e91aeec8f07fd4dc4cc7f48dbd7 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sun, 21 Sep 2025 13:41:06 -0500 Subject: [PATCH 01/11] add FilterCategoryAccessor modeled after FilterValueAccessor --- lonboard/traits.py | 172 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/lonboard/traits.py b/lonboard/traits.py index a0a61862..e247f27f 100644 --- a/lonboard/traits.py +++ b/lonboard/traits.py @@ -823,6 +823,178 @@ def validate( return value.rechunk(max_chunksize=obj._rows_per_chunk) +class FilterCategoryAccessor(FixedErrorTraitType): + """Validate input for `get_filter_category`. + + A trait to validate input for the `get_filter_category` accessor added by the + [`DataFilterExtension`][lonboard.layer_extension.DataFilterExtension], which can + have between 1 and 4 values per row. + + + Various input is allowed: + + - An `int` or `float`. This will be used as the value for all objects. The + `category_size` of the + [`DataFilterExtension`][lonboard.layer_extension.DataFilterExtension] instance + must be 1. + - A one-dimensional numpy `ndarray` with a numeric data type. Each value in the array will + be used as the value for the object at the same row index. The `category_size` of + the [`DataFilterExtension`][lonboard.layer_extension.DataFilterExtension] instance + must be 1. + - A two-dimensional numpy `ndarray` with a numeric data type. Each value in the array will + be used as the value for the object at the same row index. The `category_size` of + the [`DataFilterExtension`][lonboard.layer_extension.DataFilterExtension] instance + must match the size of the second dimension of the array. + - A pandas `Series` with a numeric data type. Each value in the array will be used as + the value for the object at the same row index. The `category_size` of the + [`DataFilterExtension`][lonboard.layer_extension.DataFilterExtension] instance + must be 1. + - A pyarrow [`FloatArray`][pyarrow.FloatArray], [`DoubleArray`][pyarrow.DoubleArray] + or [`ChunkedArray`][pyarrow.ChunkedArray] containing either a `FloatArray` or + `DoubleArray`. Each value in the array will be used as the value for the object at + the same row index. The `category_size` of the + [`DataFilterExtension`][lonboard.layer_extension.DataFilterExtension] instance + must be 1. + + Alternatively, you can pass any corresponding Arrow data structure from a library + that implements the [Arrow PyCapsule + Interface](https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html). + - A pyarrow [`FixedSizeListArray`][pyarrow.FixedSizeListArray] or + [`ChunkedArray`][pyarrow.ChunkedArray] containing `FixedSizeListArray`s. The `category_size` of + the [`DataFilterExtension`][lonboard.layer_extension.DataFilterExtension] instance + must match the list size. + + Alternatively, you can pass any corresponding Arrow data structure from a library + that implements the [Arrow PyCapsule + Interface](https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html). + """ + + default_value = None + info_text = "a value or numpy ndarray or Arrow array representing an array of data" + + def __init__( + self: TraitType, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.tag(sync=True, **ACCESSOR_SERIALIZATION) + + def _pandas_to_numpy( + self, + obj: BaseArrowLayer, + value: Any, + category_size: int, + ) -> np.ndarray: + # Assert that category_size == 1 for a pandas series. + # Pandas series can technically contain Python list objects inside them, but + # for simplicity we disallow that. + if category_size != 1: + self.error(obj, value, info="category_size==1 with pandas Series") + + # Cast pandas Series to numpy ndarray + return np.asarray(value) + + def _numpy_to_arrow( + self, + obj: BaseArrowLayer, + value: Any, + category_size: int, + ) -> ChunkedArray: + if len(value.shape) == 1: + if category_size != 1: + self.error(obj, value, info="category_size==1 with 1-D numpy array") + array = fixed_size_list_array(value, category_size) + return ChunkedArray(array) + + if len(value.shape) != 2: + self.error(obj, value, info="1-D or 2-D numpy array") + + if value.shape[1] != category_size: + self.error( + obj, + value, + info=( + f"category_size ({category_size}) to match 2nd dimension of numpy array" + ), + ) + array = fixed_size_list_array(value, category_size) + return ChunkedArray([array]) + + def validate( + self, + obj: BaseArrowLayer, + value: Any, + ) -> str | float | tuple | list | ChunkedArray: + # Find the data filter extension in the attributes of the parent object so we + # can validate against the filter size. + data_filter_extension = [ + ext + for ext in obj.extensions + if ext._extension_type == "data-filter" # type: ignore + ] + assert len(data_filter_extension) == 1 + category_size = data_filter_extension[0].category_size # type: ignore + + if isinstance(value, (int, float, str)): + if category_size != 1: + self.error(obj, value, info="category_size==1 with scalar value") + return value + + if isinstance(value, (tuple, list)): + if category_size != len(value): + self.error( + obj, + value, + info=f"category_size ({category_size}) to match length of tuple/list", + ) + return value + + # pandas Series + if ( + value.__class__.__module__.startswith("pandas") + and value.__class__.__name__ == "Series" + ): + value = self._pandas_to_numpy(obj, value, category_size) + + if isinstance(value, np.ndarray): + value = self._numpy_to_arrow(obj, value, category_size) + elif hasattr(value, "__arrow_c_array__"): + value = ChunkedArray([Array.from_arrow(value)]) + elif hasattr(value, "__arrow_c_stream__"): + value = ChunkedArray.from_arrow(value) + else: + self.error(obj, value) + + assert isinstance(value, ChunkedArray) + + # Allowed inputs are either a FixedSizeListArray or array. + if not DataType.is_fixed_size_list(value.type): + if category_size != 1: + self.error( + obj, + value, + info="category_size==1 with non-FixedSizeList type arrow array", + ) + + return value + + # We have a FixedSizeListArray + if category_size != value.type.list_size: + self.error( + obj, + value, + info=( + f"category_size ({category_size}) to match list size of " + "FixedSizeList arrow array" + ), + ) + + value_type = value.type.value_type + assert value_type is not None + return value.rechunk(max_chunksize=obj._rows_per_chunk) + + class NormalAccessor(FixedErrorTraitType): """A representation of a deck.gl "normal" accessor. From 53abc41588467ab4f9cd4905a99cf190171cd3a9 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sun, 21 Sep 2025 13:43:29 -0500 Subject: [PATCH 02/11] use FilterCategoryAccessor for get_filter_category, and set the min values of filter_size and category_size to 0 so DFE can use just a range or category filter --- lonboard/layer_extension.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lonboard/layer_extension.py b/lonboard/layer_extension.py index 306a3c94..a7ca7bd1 100644 --- a/lonboard/layer_extension.py +++ b/lonboard/layer_extension.py @@ -5,6 +5,7 @@ from lonboard._base import BaseExtension from lonboard.traits import ( DashArrayAccessor, + FilterCategoryAccessor, FilterValueAccessor, FloatAccessor, PointAccessor, @@ -353,10 +354,12 @@ class DataFilterExtension(BaseExtension): "filter_transform_size": t.Bool(default_value=True).tag(sync=True), "filter_transform_color": t.Bool(default_value=True).tag(sync=True), "get_filter_value": FilterValueAccessor(default_value=None, allow_none=True), - "get_filter_category": FilterValueAccessor(default_value=None, allow_none=True), + "get_filter_category": FilterCategoryAccessor( + default_value=None, allow_none=True, + ), } - filter_size = t.Int(None, min=1, max=4, allow_none=True).tag(sync=True) + filter_size = t.Int(None, min=0, max=4, allow_none=True).tag(sync=True) """The size of the filter (number of columns to filter by). The data filter can show/hide data based on 1-4 numeric properties of each object. @@ -365,7 +368,7 @@ class DataFilterExtension(BaseExtension): - Default 1. """ - category_size = t.Int(None, min=1, max=4, allow_none=True).tag(sync=True) + category_size = t.Int(None, min=0, max=4, allow_none=True).tag(sync=True) """The size of the category filter (number of columns to filter by). The category filter can show/hide data based on 1-4 properties of each object. From 8ff247e967a8f1b59e6b7bf1d9e56b067168cee5 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sun, 21 Sep 2025 13:49:16 -0500 Subject: [PATCH 03/11] pass filterSize and categorySize if they're both defined to the _DatafilterExtension --- src/model/extension.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/model/extension.ts b/src/model/extension.ts index a361ddf6..aa128247 100644 --- a/src/model/extension.ts +++ b/src/model/extension.ts @@ -146,11 +146,18 @@ export class DataFilterExtension extends BaseExtensionModel { } extensionInstance(): _DataFilterExtension | null { - if (isDefined(this.filterSize)) { + if (isDefined(this.filterSize) && isDefined(this.categorySize)) { + const props = { + ...(isDefined(this.filterSize) ? { filterSize: this.filterSize } : {}), + ...(isDefined(this.categorySize) + ? { categorySize: this.categorySize } + : {}), + }; + return new _DataFilterExtension(props); + } else if (isDefined(this.filterSize)) { const props = { ...(isDefined(this.filterSize) ? { filterSize: this.filterSize } : {}), }; - // console.log("ext props", props); return new _DataFilterExtension(props); } else if (isDefined(this.categorySize)) { const props = { @@ -158,7 +165,6 @@ export class DataFilterExtension extends BaseExtensionModel { ? { categorySize: this.categorySize } : {}), }; - // console.log("ext props", props); return new _DataFilterExtension(props); } else { return null; From 5c7f72e4f0f830e741fb86649828b2805ddf664e Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sun, 21 Sep 2025 13:50:56 -0500 Subject: [PATCH 04/11] add temporary example notebook --- examples/!category_filter.ipynb | 141 ++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 examples/!category_filter.ipynb diff --git a/examples/!category_filter.ipynb b/examples/!category_filter.ipynb new file mode 100644 index 00000000..b58e15ba --- /dev/null +++ b/examples/!category_filter.ipynb @@ -0,0 +1,141 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "9ffdeba2", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "f45976dba1f541268fa76936193821c3", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(Map(basemap_style= None: # noqa\n", + " # when we change the selector, update the filter on the layer.\n", + " point_layer.filter_categories = cat_selector.value\n", + "\n", + "\n", + "cat_selector.observe(on_cat_selector_change, names=\"value\")\n", + "\n", + "ipywidgets.VBox([m, filter_enabled_w, cat_selector])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ce25544c", + "metadata": {}, + "outputs": [], + "source": [ + "gdf[\"number\"].values[0].item()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "lonboard_category_filter", + "language": "python", + "name": "lonboard_category_filter" + }, + "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.12.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From a1f519bde16e1ae19ee0267c3e7a4525cebace69 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sun, 21 Sep 2025 14:37:32 -0500 Subject: [PATCH 05/11] lint --- examples/!category_filter.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/!category_filter.ipynb b/examples/!category_filter.ipynb index b58e15ba..4eed8fbf 100644 --- a/examples/!category_filter.ipynb +++ b/examples/!category_filter.ipynb @@ -30,7 +30,7 @@ "\n", "import geopandas as gpd\n", "import ipywidgets\n", - "import pyarrow as pa\n", + "import pyarrow as pa # noqa\n", "from shapely.geometry import Point\n", "\n", "import lonboard\n", @@ -68,7 +68,7 @@ " get_fill_color=(0, 255, 0),\n", " radius_min_pixels=10,\n", " extensions=[\n", - " DataFilterExtension(filter_size=0, category_size=1)\n", + " DataFilterExtension(filter_size=0, category_size=1),\n", " ], # no range filter, just a category\n", " get_filter_category=gdf[cat_col], # use the cat column for the filter category\n", ")\n", From e5281beb85edf824684f0b6cf4698f8e9c410c7f Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sun, 21 Sep 2025 14:42:06 -0500 Subject: [PATCH 06/11] more linting... --- lonboard/layer_extension.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lonboard/layer_extension.py b/lonboard/layer_extension.py index a7ca7bd1..192e76a5 100644 --- a/lonboard/layer_extension.py +++ b/lonboard/layer_extension.py @@ -355,7 +355,8 @@ class DataFilterExtension(BaseExtension): "filter_transform_color": t.Bool(default_value=True).tag(sync=True), "get_filter_value": FilterValueAccessor(default_value=None, allow_none=True), "get_filter_category": FilterCategoryAccessor( - default_value=None, allow_none=True, + default_value=None, + allow_none=True, ), } From 2403779818af7c48a02d751ef404bdf5447f05a1 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:06:59 -0500 Subject: [PATCH 07/11] simplify if/else blocks and add defaults if None on python side --- src/model/extension.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/model/extension.ts b/src/model/extension.ts index aa128247..0d89a9e3 100644 --- a/src/model/extension.ts +++ b/src/model/extension.ts @@ -146,23 +146,13 @@ export class DataFilterExtension extends BaseExtensionModel { } extensionInstance(): _DataFilterExtension | null { - if (isDefined(this.filterSize) && isDefined(this.categorySize)) { + if (isDefined(this.filterSize) || isDefined(this.categorySize)) { const props = { - ...(isDefined(this.filterSize) ? { filterSize: this.filterSize } : {}), - ...(isDefined(this.categorySize) - ? { categorySize: this.categorySize } + ...(isDefined(this.filterSize) + ? { filterSize: this.filterSize != null ? this.filterSize : 0 } : {}), - }; - return new _DataFilterExtension(props); - } else if (isDefined(this.filterSize)) { - const props = { - ...(isDefined(this.filterSize) ? { filterSize: this.filterSize } : {}), - }; - return new _DataFilterExtension(props); - } else if (isDefined(this.categorySize)) { - const props = { ...(isDefined(this.categorySize) - ? { categorySize: this.categorySize } + ? { categorySize: this.categorySize != null ? this.categorySize : 0 } : {}), }; return new _DataFilterExtension(props); From e4625c20d61c0b9cae0e4d0ee365fd5aeed9e765 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:08:39 -0500 Subject: [PATCH 08/11] change min values to 1 and set default to 1 for filter_size --- lonboard/layer_extension.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lonboard/layer_extension.py b/lonboard/layer_extension.py index 192e76a5..c4ce4b08 100644 --- a/lonboard/layer_extension.py +++ b/lonboard/layer_extension.py @@ -360,7 +360,7 @@ class DataFilterExtension(BaseExtension): ), } - filter_size = t.Int(None, min=0, max=4, allow_none=True).tag(sync=True) + filter_size = t.Int(None, min=1, max=4, allow_none=True).tag(sync=True) """The size of the filter (number of columns to filter by). The data filter can show/hide data based on 1-4 numeric properties of each object. @@ -369,7 +369,7 @@ class DataFilterExtension(BaseExtension): - Default 1. """ - category_size = t.Int(None, min=0, max=4, allow_none=True).tag(sync=True) + category_size = t.Int(None, min=1, max=4, allow_none=True).tag(sync=True) """The size of the category filter (number of columns to filter by). The category filter can show/hide data based on 1-4 properties of each object. From 647a111b053785433e122da99469758504de80c2 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:10:53 -0500 Subject: [PATCH 09/11] default category_size to None, filter_size to 1 --- lonboard/layer_extension.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lonboard/layer_extension.py b/lonboard/layer_extension.py index c4ce4b08..8e07fad2 100644 --- a/lonboard/layer_extension.py +++ b/lonboard/layer_extension.py @@ -360,7 +360,7 @@ class DataFilterExtension(BaseExtension): ), } - filter_size = t.Int(None, min=1, max=4, allow_none=True).tag(sync=True) + filter_size = t.Int(1, min=1, max=4, allow_none=True).tag(sync=True) """The size of the filter (number of columns to filter by). The data filter can show/hide data based on 1-4 numeric properties of each object. @@ -375,7 +375,7 @@ class DataFilterExtension(BaseExtension): The category filter can show/hide data based on 1-4 properties of each object. - Type: `int`. This is required if using category-based filtering. - - Default 0. + - Default None. """ From b0086ad991eaf73e0007105e850657e30535e43d Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:11:19 -0500 Subject: [PATCH 10/11] added a few more cells to make sure I didnt break anything :) --- examples/!category_filter.ipynb | 76 +++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/examples/!category_filter.ipynb b/examples/!category_filter.ipynb index 4eed8fbf..058a3785 100644 --- a/examples/!category_filter.ipynb +++ b/examples/!category_filter.ipynb @@ -5,23 +5,7 @@ "execution_count": null, "id": "9ffdeba2", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "f45976dba1f541268fa76936193821c3", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(Map(basemap_style= Date: Wed, 15 Oct 2025 19:24:06 -0500 Subject: [PATCH 11/11] lint the trowaway notebook --- examples/!category_filter.ipynb | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/examples/!category_filter.ipynb b/examples/!category_filter.ipynb index 058a3785..b8510b37 100644 --- a/examples/!category_filter.ipynb +++ b/examples/!category_filter.ipynb @@ -108,7 +108,7 @@ ")\n", "\n", "m2 = lonboard.Map(layers=[point_layer2], basemap_style=CartoBasemap.DarkMatter)\n", - "point_layer2.filter_range=[0, 5]\n", + "point_layer2.filter_range = [0, 5]\n", "m2" ] }, @@ -119,7 +119,7 @@ "metadata": {}, "outputs": [], "source": [ - "point_layer2.filter_range=[1, 4]" + "point_layer2.filter_range = [1, 4]" ] }, { @@ -136,12 +136,14 @@ " extensions=[\n", " DataFilterExtension(filter_size=1, category_size=1),\n", " ], # no category filter, just a range\n", - " get_filter_category=gdf[\"float_col\"], # use the float column for the filter category\n", - " get_filter_value=gdf[\"int_col\"], # use the int column for the filter category \n", + " get_filter_category=gdf[\n", + " \"float_col\"\n", + " ], # use the float column for the filter category\n", + " get_filter_value=gdf[\"int_col\"], # use the int column for the filter category\n", ")\n", "\n", "point_layer3.filter_categories = [1.5]\n", - "point_layer3.filter_range=[0, 3]\n", + "point_layer3.filter_range = [0, 3]\n", "m3 = lonboard.Map(layers=[point_layer3], basemap_style=CartoBasemap.DarkMatter)\n", "m3" ] @@ -153,7 +155,7 @@ "metadata": {}, "outputs": [], "source": [ - "point_layer3.filter_range=[0, 5]" + "point_layer3.filter_range = [0, 5]" ] } ],