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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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]" ] } ], From 90426c037c256b9336e0c5413b442588bd3fdcaa Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Mon, 27 Oct 2025 16:16:35 -0500 Subject: [PATCH 12/22] update docstring for filter/category_size being optional --- 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 8e07fad2..ac5bd02a 100644 --- a/lonboard/layer_extension.py +++ b/lonboard/layer_extension.py @@ -365,7 +365,7 @@ class DataFilterExtension(BaseExtension): The data filter can show/hide data based on 1-4 numeric properties of each object. - - Type: `int`. This is required if using range-based filtering. + - Type: `int`, optional. This is required if using range-based filtering. - Default 1. """ @@ -374,7 +374,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. + - Type: `int`, optional. This is required if using category-based filtering. - Default None. """ From 4270606fab35be4fe9ffe9e34e220e3e3d0cec70 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sat, 1 Nov 2025 07:51:20 -0500 Subject: [PATCH 13/22] need to use ravel("C") for multiple categories --- lonboard/traits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lonboard/traits.py b/lonboard/traits.py index 7ac21272..97e21d2b 100644 --- a/lonboard/traits.py +++ b/lonboard/traits.py @@ -919,7 +919,7 @@ def _numpy_to_arrow( f"category_size ({category_size}) to match 2nd dimension of numpy array" ), ) - array = fixed_size_list_array(value, category_size) + array = fixed_size_list_array(value.ravel("C"), category_size) return ChunkedArray([array]) def validate( From 00b0763f07708b5e3477932e1539cf0ec9ef7353 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sat, 1 Nov 2025 17:49:56 -0500 Subject: [PATCH 14/22] add data filter extension tests --- tests/traits/__init__.py | 0 tests/traits/test_filter_extension.py | 331 ++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 tests/traits/__init__.py create mode 100644 tests/traits/test_filter_extension.py diff --git a/tests/traits/__init__.py b/tests/traits/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/traits/test_filter_extension.py b/tests/traits/test_filter_extension.py new file mode 100644 index 00000000..dbdf2844 --- /dev/null +++ b/tests/traits/test_filter_extension.py @@ -0,0 +1,331 @@ +import arro3 +import geopandas as gpd +import pytest +from shapely.geometry import Point + +import lonboard +from lonboard.layer_extension import DataFilterExtension +from lonboard.traits import TraitError + + +@pytest.fixture +def dfe_test_df() -> gpd.GeoDataFrame: + """GeoDataframe for testing DataFilterExtension.""" + d = { + "int_col": [0, 1, 2, 3, 4, 5], + "float_col": [0.0, 1.5, 0.0, 1.5, 0.0, 1.5], + "str_col": ["even", "odd", "even", "odd", "even", "odd"], + "geometry": [ + Point(0, 0), + Point(1, 1), + Point(2, 2), + Point(3, 3), + Point(4, 4), + Point(5, 5), + ], + } + return gpd.GeoDataFrame(d, crs="EPSG:4326") + + +def test_dfe_no_args_no_get_filter_value(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE without args, no get_filter_value + layer = lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(), + ], + ) + assert len(layer.extensions) == 1 + dfe = layer.extensions[0] + assert isinstance(dfe, DataFilterExtension) + assert dfe.filter_size == 1 + assert dfe.category_size is None + assert layer.get_filter_value is None + assert layer.get_filter_category is None + + +def test_dfe_no_args_and_int_get_filter_value(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE without args and int get_filter_value + layer = lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(), + ], + get_filter_value=dfe_test_df["int_col"], + ) + assert len(layer.extensions) == 1 + dfe = layer.extensions[0] + assert isinstance(dfe, DataFilterExtension) + assert dfe.filter_size == 1 + assert dfe.category_size is None + assert isinstance(layer.get_filter_value, arro3.core.ChunkedArray) + assert layer.get_filter_category is None + + +def test_dfe_no_args_and_float_get_filter_value(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE without args and float get_filter_value + layer = lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(), + ], + get_filter_value=dfe_test_df["float_col"], + ) + assert len(layer.extensions) == 1 + dfe = layer.extensions[0] + assert isinstance(dfe, DataFilterExtension) + assert dfe.filter_size == 1 + assert dfe.category_size is None + assert isinstance(layer.get_filter_value, arro3.core.ChunkedArray) + assert layer.get_filter_category is None + + +def test_dfe_filter_size_no_get_filter_value(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with filter_size, no get_filter_value + layer = lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=1), + ], + ) + assert len(layer.extensions) == 1 + dfe = layer.extensions[0] + assert isinstance(dfe, DataFilterExtension) + assert dfe.filter_size == 1 + assert dfe.category_size is None + assert layer.get_filter_value is None + assert layer.get_filter_category is None + + +def test_dfe_filter_size_and_get_filter_value(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with filter_size and get_filter_value + layer = lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=1), + ], + get_filter_value=dfe_test_df["int_col"], + ) + assert len(layer.extensions) == 1 + dfe = layer.extensions[0] + assert isinstance(dfe, DataFilterExtension) + assert dfe.filter_size == 1 + assert dfe.category_size is None + assert isinstance(layer.get_filter_value, arro3.core.ChunkedArray) + assert layer.get_filter_category is None + + +def test_dfe_filter_size2_no_get_filter_value(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with filter_size 2, no get_filter_value + layer = lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=2), + ], + ) + assert len(layer.extensions) == 1 + dfe = layer.extensions[0] + assert isinstance(dfe, DataFilterExtension) + assert dfe.filter_size == 2 + assert dfe.category_size is None + assert layer.get_filter_value is None + assert layer.get_filter_category is None + + +def test_dfe_filter_size2_and_get_filter_value(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with filter_size 2 and get_filter_value + layer = lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=2), + ], + get_filter_value=dfe_test_df[["int_col", "float_col"]].values, + ) + assert len(layer.extensions) == 1 + dfe = layer.extensions[0] + assert isinstance(dfe, DataFilterExtension) + assert dfe.filter_size == 2 + assert dfe.category_size is None + assert isinstance(layer.get_filter_value, arro3.core.ChunkedArray) + assert layer.get_filter_category is None + + +def test_dfe_cat_no_get_filter_cat(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with filter_size None category_size=1, no get_filter_category + layer = lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=None, category_size=1), + ], + ) + assert len(layer.extensions) == 1 + dfe = layer.extensions[0] + assert isinstance(dfe, DataFilterExtension) + assert dfe.filter_size is None + assert dfe.category_size == 1 + assert layer.get_filter_value is None + assert layer.get_filter_category is None + + +def test_dfe_cat_and_int_get_filter_cat(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with filter_size None category_size=1 and get_filter_category + layer = lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=None, category_size=1), + ], + get_filter_category=dfe_test_df["int_col"], + ) + assert len(layer.extensions) == 1 + dfe = layer.extensions[0] + assert isinstance(dfe, DataFilterExtension) + assert dfe.filter_size is None + assert dfe.category_size == 1 + assert layer.get_filter_value is None + assert isinstance(layer.get_filter_category, arro3.core.ChunkedArray) + + +def test_dfe_cat_and_float_get_filter_cat(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with filter_size None category_size=1 and get_filter_category + layer = lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=None, category_size=1), + ], + get_filter_category=dfe_test_df["float_col"], + ) + assert len(layer.extensions) == 1 + dfe = layer.extensions[0] + assert isinstance(dfe, DataFilterExtension) + assert dfe.filter_size is None + assert dfe.category_size == 1 + assert layer.get_filter_value is None + assert isinstance(layer.get_filter_category, arro3.core.ChunkedArray) + + +def test_dfe_cat2_get_filter_cat(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with filter_size None category_size=2 and get_filter_category + layer = lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=None, category_size=2), + ], + get_filter_category=dfe_test_df[["int_col", "float_col"]].values, + ) + assert len(layer.extensions) == 1 + dfe = layer.extensions[0] + assert isinstance(dfe, DataFilterExtension) + assert dfe.filter_size is None + assert dfe.category_size == 2 + assert layer.get_filter_value is None + assert isinstance(layer.get_filter_category, arro3.core.ChunkedArray) + + +def test_dfe_value_and_cat_no_get_filter_value_or_category( + dfe_test_df: gpd.GeoDataFrame, +): + ## Test DFE with filter_size=1 category_size=1 and no get_filter_value or get_filter_category + layer = lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=1, category_size=1), + ], + ) + assert len(layer.extensions) == 1 + dfe = layer.extensions[0] + assert isinstance(dfe, DataFilterExtension) + assert dfe.filter_size == 1 + assert dfe.category_size == 1 + assert layer.get_filter_value is None + assert layer.get_filter_category is None + + +def test_dfe_value_and_cat_and_get_filter_value_category(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with filter_size=1 category_size=1 and get_filter_value/get_filter_category + layer = lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=1, category_size=1), + ], + get_filter_value=dfe_test_df["int_col"], + get_filter_category=dfe_test_df["float_col"], + ) + assert len(layer.extensions) == 1 + dfe = layer.extensions[0] + assert isinstance(dfe, DataFilterExtension) + assert dfe.filter_size == 1 + assert dfe.category_size == 1 + assert isinstance(layer.get_filter_value, arro3.core.ChunkedArray) + assert isinstance(layer.get_filter_category, arro3.core.ChunkedArray) + + +def test_dfe_filter_size_none_with_filter_value(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with filter_size=None category_size=1 and get_filter_value provided raises + with pytest.raises(TraitError): + lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=None, category_size=1), + ], + get_filter_value=dfe_test_df["float_col"], + ) + + +def test_dfe_category_size_none_with_filter_category(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with filter_size=1 category_size=None and get_filter_category provided raises + with pytest.raises(TraitError): + lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=1, category_size=None), + ], + get_filter_category=dfe_test_df["float_col"], + ) + + +def test_dfe_wrong_get_filter_value_size(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with filter_size=2 and get_filter_value with 1-D array raises + with pytest.raises(TraitError): + lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=2, category_size=None), + ], + get_filter_value=dfe_test_df["float_col"], + ) + + +def test_dfe_wrong_get_filter_value_size2(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with filter_size=1 and get_filter_value with 2-D array raises + with pytest.raises(TraitError): + lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=1, category_size=None), + ], + get_filter_value=dfe_test_df[["int_col", "float_col"]].values, + ) + + +def test_dfe_wrong_get_filter_category_size(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with category_size=2 and get_filter_category with 1-D array raises + with pytest.raises(TraitError): + lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=None, category_size=2), + ], + get_filter_category=dfe_test_df["float_col"], + ) + + +def test_dfe_wrong_get_filter_category_size2(dfe_test_df: gpd.GeoDataFrame): + ## Test DFE with category_size=1 and get_filter_category with 2-D array raises + with pytest.raises(TraitError): + lonboard.ScatterplotLayer.from_geopandas( + dfe_test_df, + extensions=[ + DataFilterExtension(filter_size=None, category_size=1), + ], + get_filter_category=dfe_test_df[["int_col", "float_col"]].values, + ) From d733fce5c13ebc9d22f8620031b72872c1189686 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sat, 1 Nov 2025 18:45:13 -0500 Subject: [PATCH 15/22] move FilterCategoryAccessor to _extensions.py --- examples/!category_filter.ipynb | 4 +- lonboard/traits/__init__.py | 3 +- lonboard/traits/_extensions.py | 172 ++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 3 deletions(-) diff --git a/examples/!category_filter.ipynb b/examples/!category_filter.ipynb index b8510b37..6b9fa45a 100644 --- a/examples/!category_filter.ipynb +++ b/examples/!category_filter.ipynb @@ -52,7 +52,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=None, 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", @@ -102,7 +102,7 @@ " get_fill_color=(0, 255, 0),\n", " radius_min_pixels=10,\n", " extensions=[\n", - " DataFilterExtension(filter_size=1, category_size=0),\n", + " DataFilterExtension(filter_size=1, category_size=None),\n", " ], # no category filter, just a range\n", " get_filter_value=gdf[\"int_col\"], # use the int_col for the filter category\n", ")\n", diff --git a/lonboard/traits/__init__.py b/lonboard/traits/__init__.py index feefc5ca..12bdb233 100644 --- a/lonboard/traits/__init__.py +++ b/lonboard/traits/__init__.py @@ -7,7 +7,7 @@ from ._a5 import A5Accessor from ._base import FixedErrorTraitType, VariableLengthTuple from ._color import ColorAccessor -from ._extensions import DashArrayAccessor, FilterValueAccessor +from ._extensions import DashArrayAccessor, FilterCategoryAccessor, FilterValueAccessor from ._float import FloatAccessor from ._h3 import H3Accessor from ._map import BasemapUrl, HeightTrait, ViewStateTrait @@ -23,6 +23,7 @@ "BasemapUrl", "ColorAccessor", "DashArrayAccessor", + "FilterCategoryAccessor", "FilterValueAccessor", "FixedErrorTraitType", "FloatAccessor", diff --git a/lonboard/traits/_extensions.py b/lonboard/traits/_extensions.py index dc3b8cf7..59baaf7c 100644 --- a/lonboard/traits/_extensions.py +++ b/lonboard/traits/_extensions.py @@ -228,6 +228,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.ravel("C"), 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 DashArrayAccessor(FixedErrorTraitType): """A trait to validate input for a deck.gl dash accessor. From 23b8fc74c2892c4a1f1f5fcf7769df8d89c95d56 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sat, 1 Nov 2025 18:49:02 -0500 Subject: [PATCH 16/22] import TraitError from traitlets --- tests/traits/test_filter_extension.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/traits/test_filter_extension.py b/tests/traits/test_filter_extension.py index dbdf2844..ec16e06a 100644 --- a/tests/traits/test_filter_extension.py +++ b/tests/traits/test_filter_extension.py @@ -2,10 +2,10 @@ import geopandas as gpd import pytest from shapely.geometry import Point +from traitlets import TraitError import lonboard from lonboard.layer_extension import DataFilterExtension -from lonboard.traits import TraitError @pytest.fixture From 6eec9d02e30e8147fb320a5a3356535c4714320d Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sat, 8 Nov 2025 18:29:55 -0600 Subject: [PATCH 17/22] remove temp notebook --- examples/!category_filter.ipynb | 183 -------------------------------- 1 file changed, 183 deletions(-) delete mode 100644 examples/!category_filter.ipynb diff --git a/examples/!category_filter.ipynb b/examples/!category_filter.ipynb deleted file mode 100644 index 6b9fa45a..00000000 --- a/examples/!category_filter.ipynb +++ /dev/null @@ -1,183 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "9ffdeba2", - "metadata": {}, - "outputs": [], - "source": [ - "# no intention of actually keeping this notebook in the repo, once it's all working good I'll make up a new doc about the category_filter\n", - "# I have added it for now to demonstrate that I've got the data filter filter_categories functionality working for numeric inputs\n", - "# looking at the deck gl docs: https://deck.gl/docs/api-reference/extensions/data-filter-extension#layer-properties\n", - "# it appears that we should be able to use strings for the categories but when I try to use string data the layer simply doesnt work\n", - "\n", - "import geopandas as gpd\n", - "import ipywidgets\n", - "import pyarrow as pa # noqa\n", - "from shapely.geometry import Point\n", - "\n", - "import lonboard\n", - "from lonboard.basemap import CartoBasemap\n", - "from lonboard.layer_extension import DataFilterExtension\n", - "\n", - "cat_col = \"int_col\"\n", - "# int_col: works\n", - "# float_col: works\n", - "# str_col: does NOT work :(\n", - "# as is it will throw an arro3 ValueError: Expected object with __arrow_c_array__ method or implementing buffer protocol.\n", - "# we can avoid the arro3 exception by using pyarrow as the input to get_filter_category when we create the layer:\n", - "# `get_filter_category=pa.array(gdf[cat_col])`\n", - "# but the layer doesn't display and throws a lot of the following WebGL error:\n", - "# GL_INVALID_OPERATION: Vertex shader input type does not match the type of the bound vertex attribute\n", - "\n", - "\n", - "d = {\n", - " \"int_col\": [0, 1, 2, 3, 4, 5],\n", - " \"float_col\": [0.0, 1.5, 0.0, 1.5, 0.0, 1.5],\n", - " \"str_col\": [\"even\", \"odd\", \"even\", \"odd\", \"even\", \"odd\"],\n", - " \"geometry\": [\n", - " Point(0, 0),\n", - " Point(1, 1),\n", - " Point(2, 2),\n", - " Point(3, 3),\n", - " Point(4, 4),\n", - " Point(5, 5),\n", - " ],\n", - "}\n", - "gdf = gpd.GeoDataFrame(d, crs=\"EPSG:4326\")\n", - "\n", - "point_layer = lonboard.ScatterplotLayer.from_geopandas(\n", - " gdf,\n", - " get_fill_color=(0, 255, 0),\n", - " radius_min_pixels=10,\n", - " extensions=[\n", - " DataFilterExtension(filter_size=None, 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", - "\n", - "m = lonboard.Map(layers=[point_layer], basemap_style=CartoBasemap.DarkMatter)\n", - "\n", - "filter_enabled_w = ipywidgets.Checkbox(\n", - " value=True,\n", - " description=\"Filter Enabled\",\n", - ")\n", - "\n", - "\n", - "def on_filter_enabled_change(change): # noqa\n", - " # when we change the checkbox, toggle filtering on the layer\n", - " point_layer.filter_enabled = filter_enabled_w.value\n", - "\n", - "\n", - "filter_enabled_w.observe(on_filter_enabled_change, names=\"value\")\n", - "\n", - "cat_selector = ipywidgets.SelectMultiple( # make a select multiple so we can see interaction on the map\n", - " options=list(gdf[cat_col].unique()),\n", - " value=[list(gdf[cat_col].unique())[0]],\n", - " description=\"Category\",\n", - " disabled=False,\n", - ")\n", - "\n", - "\n", - "def on_cat_selector_change(change) -> 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": [ - "point_layer2 = lonboard.ScatterplotLayer.from_geopandas(\n", - " gdf,\n", - " get_fill_color=(0, 255, 0),\n", - " radius_min_pixels=10,\n", - " extensions=[\n", - " DataFilterExtension(filter_size=1, category_size=None),\n", - " ], # no category filter, just a range\n", - " get_filter_value=gdf[\"int_col\"], # use the int_col for the filter category\n", - ")\n", - "\n", - "m2 = lonboard.Map(layers=[point_layer2], basemap_style=CartoBasemap.DarkMatter)\n", - "point_layer2.filter_range = [0, 5]\n", - "m2" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ef71f006", - "metadata": {}, - "outputs": [], - "source": [ - "point_layer2.filter_range = [1, 4]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10e27c5d", - "metadata": {}, - "outputs": [], - "source": [ - "point_layer3 = lonboard.ScatterplotLayer.from_geopandas(\n", - " gdf,\n", - " get_fill_color=(0, 255, 0),\n", - " radius_min_pixels=10,\n", - " extensions=[\n", - " DataFilterExtension(filter_size=1, category_size=1),\n", - " ], # no category filter, just a range\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", - "m3 = lonboard.Map(layers=[point_layer3], basemap_style=CartoBasemap.DarkMatter)\n", - "m3" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c3f48195", - "metadata": {}, - "outputs": [], - "source": [ - "point_layer3.filter_range = [0, 5]" - ] - } - ], - "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 95ca5b09f14a404ce5d0de6b505a4a058e3d1af5 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sat, 8 Nov 2025 19:31:49 -0500 Subject: [PATCH 18/22] add data-filter-extension-categorical example --- .../data-filter-extension-categorical.ipynb | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 examples/data-filter-extension-categorical.ipynb diff --git a/examples/data-filter-extension-categorical.ipynb b/examples/data-filter-extension-categorical.ipynb new file mode 100644 index 00000000..af44c691 --- /dev/null +++ b/examples/data-filter-extension-categorical.ipynb @@ -0,0 +1,234 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "5a203c06-68a6-4335-9037-aef706980245", + "metadata": {}, + "outputs": [], + "source": [ + "# /// script\n", + "# requires-python = \">=3.12\"\n", + "# dependencies = [\n", + "# \"geodatasets\",\n", + "# \"geopandas\",\n", + "# \"ipywidgets\",\n", + "# \"lonboard\",\n", + "# \"numpy\",\n", + "# \"palettable\",\n", + "# \"pandas\",\n", + "# \"shapely\",\n", + "# ]\n", + "# ///" + ] + }, + { + "cell_type": "markdown", + "id": "ad4207fc-7b6d-4301-b79e-34ff1d961994", + "metadata": {}, + "source": [ + "# Categorical Filtering with the DataFilterExtension\n", + "\n", + "The `DataFilterExtension` adds GPU-based data filtering functionalities to layers, allowing the layer to show/hide objects based on user-defined properties." + ] + }, + { + "cell_type": "markdown", + "id": "0eba4205-2605-4922-be6a-ee71a4d8e389", + "metadata": {}, + "source": [ + "## Dependencies\n", + "\n", + "Install [`uv`](https://docs.astral.sh/uv) and then launch this notebook with:\n", + "\n", + "```\n", + "uvx juv run examples/data-filter-extension-categorical.ipynb\n", + "```\n", + "\n", + "(The `uvx` command is included when installing `uv`)." + ] + }, + { + "cell_type": "markdown", + "id": "a4a41490-4c62-4ed1-90f9-5126fa3e532f", + "metadata": {}, + "source": [ + "## Categorical Filtering\n", + "In this example the `DataFilterExtension` will be used to filter the display the home sales dataset from geodatasets based on the number of bedrooms, bathrooms.\n", + "\n", + "To demonstrate, we'll create:\n", + "\n", + "1. A Geopandas GeoDataFrame of home sales data.\n", + "2. A Lonboard ScatterPlotLayer from the GeoDataFrame that has a DataFilterExtension set up for categorical filtering.\n", + "3. Some IPyWidgets linked to the ScatterPlotLayer's `filter_categories` property to allow us to interactively filter the points on the map." + ] + }, + { + "cell_type": "markdown", + "id": "799e41b8-e75a-453c-a9ac-59ed6e254cd7", + "metadata": {}, + "source": [ + "### Imports" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b7965b2a-4e93-438f-81ea-cf39917fb654", + "metadata": {}, + "outputs": [], + "source": [ + "import geodatasets\n", + "import geopandas\n", + "import traitlets\n", + "from ipywidgets import Button, HBox, SelectMultiple, VBox\n", + "from palettable.colorbrewer.diverging import RdYlGn_11\n", + "\n", + "import lonboard\n", + "from lonboard import Map, ScatterplotLayer\n", + "from lonboard.layer_extension import DataFilterExtension" + ] + }, + { + "cell_type": "markdown", + "id": "6a703ab0-6b15-4e6e-b45b-ed915fa4d670", + "metadata": {}, + "source": [ + "### Reading the Home Sales Data\n", + "\n", + "This example makes use the geodatasets python package to access some spatial data easily.\n", + "\n", + "Calling geodatasets.get_path() will download data the specified data to the machine and return the path to the downloaded file. If the file has already been downloaded it will simply return the path to the file. [See downloading and caching](https://geodatasets.readthedocs.io/en/latest/introduction.html#downloading-and-caching) for further details." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "78c0a363-83dd-4dee-8ac6-10ff67e8e817", + "metadata": {}, + "outputs": [], + "source": [ + "home_sales_df = geopandas.read_file(geodatasets.get_path(\"geoda.home_sales\"))[[\"price\", \"bedrooms\", \"bathrooms\", \"geometry\"]]\n", + "home_sales_df" + ] + }, + { + "cell_type": "markdown", + "id": "c295793b-fc34-4e34-a2a0-6f941d7bf8fd", + "metadata": {}, + "source": [ + "### Create the `ScatterplotLayer` with a `DataFilterExtension` extension for categorical filtering\n", + "\n", + "The `DataFilterExtension` will be cretated with `filter_size=None` to indicate we do not want to use a range filter, and `category_size=2` to indicate we want to use two different categories from the data to filter the data with explicit values.\n", + "\n", + "The points in the layer will be symbolized based on a continuous colormap of the price. Lower priced homes will be red, and higher priced homes will be green. We'll throw out the upper and lower 5% of value from the color map so the upper and lower outliers do not influence the colormap." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14a311f4-7344-496d-a9b3-7ec8eaee5cef", + "metadata": {}, + "outputs": [], + "source": [ + "min_bound = home_sales_df[\"price\"].quantile(.05)\n", + "max_bound = home_sales_df[\"price\"].quantile(.95)\n", + "price = home_sales_df[\"price\"]\n", + "normalized_price = (price - min_bound) / (max_bound - min_bound)\n", + "\n", + "home_sale_layer = ScatterplotLayer.from_geopandas(\n", + " home_sales_df,\n", + " get_fill_color = lonboard.colormap.apply_continuous_cmap(normalized_price, RdYlGn_11),\n", + " radius_min_pixels=5,\n", + " extensions=[\n", + " DataFilterExtension(filter_size=None, category_size=2),\n", + " ],\n", + " get_filter_category=home_sales_df[[\"bedrooms\", \"bathrooms\"]].values,\n", + " filter_categories=[[], []],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "83854bcd-a17c-4d2a-91b7-c6626dd67ede", + "metadata": {}, + "source": [ + "### Create the iPyWidgets to interact with the `DataFilterExtension`\n", + "\n", + "Since we want to only display the points which are tied to specific number of bedrooms and bathrooms we'll:\n", + "\n", + "1. Create two ipywidgets `SelectMultiple` widgets which will hold the different numbers of bedrooms and bathrooms in the home sales data.\n", + "2. Create two ipywidgets `Button` widgets which will clear the selected values for the bedrooms and bathrooms.\n", + "4. Observe the changes made to the `SelectMultiple` widgets to update the layer's `filter_categories` property.\n", + "\n", + "This will enable us to select one or more number of bedrooms or bathrooms and have the map instantly react to disply only the data that matches the selections. If a select widget does not have a selection, all the values from that selector will be used." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2ea817c2-7a6d-4b43-99f4-ae0f638151e9", + "metadata": {}, + "outputs": [], + "source": [ + "unique_bedrooms_values=list(home_sales_df[\"bedrooms\"].sort_values().unique())\n", + "unique_bathrooms_values=list(home_sales_df[\"bathrooms\"].sort_values().unique())\n", + "\n", + "bedrooms_select = SelectMultiple(description=\"Bedrooms\", options=unique_bedrooms_values)\n", + "bathrooms_select = SelectMultiple(description=\"Bathrooms\", options=unique_bathrooms_values)\n", + "\n", + "def on_select_change(_:traitlets.utils.bunch.Bunch=None)->None:\n", + " \"\"\"Set the layer's filter_categories property based on widget selections.\"\"\"\n", + " bedrooms = bedrooms_select.value\n", + " bathrooms = bathrooms_select.value\n", + " if len(bedrooms) == 0:\n", + " bedrooms = unique_bedrooms_values\n", + " if len(bathrooms) == 0:\n", + " bathrooms = unique_bathrooms_values\n", + " home_sale_layer.filter_categories = [bedrooms, bathrooms]\n", + "bedrooms_select.observe(on_select_change, \"value\")\n", + "bathrooms_select.observe(on_select_change, \"value\")\n", + "\n", + "clear_bedrooms_button = Button(description=\"Clear Bedrooms\")\n", + "def clear_bedrooms(_:Button)->None:\n", + " bedrooms_select.value = []\n", + "clear_bedrooms_button.on_click(clear_bedrooms)\n", + "\n", + "clear_bathrooms_button = Button(description=\"Clear Bathrooms\")\n", + "def clear_bathrooms(_:Button)->None:\n", + " bathrooms_select.value = []\n", + "clear_bathrooms_button.on_click(clear_bathrooms)\n", + "\n", + "home_sale_map = Map(layers=[home_sale_layer], basemap=lonboard.basemap.MaplibreBasemap())\n", + "on_select_change() # fire the function once to make initially set the layer's filter_categories, and display all points\n", + "\n", + "display(home_sale_map)\n", + "display(HBox([\n", + " VBox([bedrooms_select, clear_bedrooms_button]),\n", + " VBox([bathrooms_select, clear_bathrooms_button]),\n", + "]))" + ] + } + ], + "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 a5b1c039e0757e4ac6ced1d87c4edfa3c7b21862 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sat, 8 Nov 2025 18:36:35 -0600 Subject: [PATCH 19/22] ruff format --- .../data-filter-extension-categorical.ipynb | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/examples/data-filter-extension-categorical.ipynb b/examples/data-filter-extension-categorical.ipynb index af44c691..1f3b2e97 100644 --- a/examples/data-filter-extension-categorical.ipynb +++ b/examples/data-filter-extension-categorical.ipynb @@ -108,7 +108,9 @@ "metadata": {}, "outputs": [], "source": [ - "home_sales_df = geopandas.read_file(geodatasets.get_path(\"geoda.home_sales\"))[[\"price\", \"bedrooms\", \"bathrooms\", \"geometry\"]]\n", + "home_sales_df = geopandas.read_file(geodatasets.get_path(\"geoda.home_sales\"))[\n", + " [\"price\", \"bedrooms\", \"bathrooms\", \"geometry\"]\n", + "]\n", "home_sales_df" ] }, @@ -131,14 +133,14 @@ "metadata": {}, "outputs": [], "source": [ - "min_bound = home_sales_df[\"price\"].quantile(.05)\n", - "max_bound = home_sales_df[\"price\"].quantile(.95)\n", + "min_bound = home_sales_df[\"price\"].quantile(0.05)\n", + "max_bound = home_sales_df[\"price\"].quantile(0.95)\n", "price = home_sales_df[\"price\"]\n", "normalized_price = (price - min_bound) / (max_bound - min_bound)\n", "\n", "home_sale_layer = ScatterplotLayer.from_geopandas(\n", " home_sales_df,\n", - " get_fill_color = lonboard.colormap.apply_continuous_cmap(normalized_price, RdYlGn_11),\n", + " get_fill_color=lonboard.colormap.apply_continuous_cmap(normalized_price, RdYlGn_11),\n", " radius_min_pixels=5,\n", " extensions=[\n", " DataFilterExtension(filter_size=None, category_size=2),\n", @@ -171,13 +173,16 @@ "metadata": {}, "outputs": [], "source": [ - "unique_bedrooms_values=list(home_sales_df[\"bedrooms\"].sort_values().unique())\n", - "unique_bathrooms_values=list(home_sales_df[\"bathrooms\"].sort_values().unique())\n", + "unique_bedrooms_values = list(home_sales_df[\"bedrooms\"].sort_values().unique())\n", + "unique_bathrooms_values = list(home_sales_df[\"bathrooms\"].sort_values().unique())\n", "\n", - "bedrooms_select = SelectMultiple(description=\"Bedrooms\", options=unique_bedrooms_values)\n", - "bathrooms_select = SelectMultiple(description=\"Bathrooms\", options=unique_bathrooms_values)\n", + "bedrooms_select = SelectMultiple(description=\"Bedrooms\", options=unique_bedrooms_values)\n", + "bathrooms_select = SelectMultiple(\n", + " description=\"Bathrooms\", options=unique_bathrooms_values\n", + ")\n", "\n", - "def on_select_change(_:traitlets.utils.bunch.Bunch=None)->None:\n", + "\n", + "def on_select_change(_: traitlets.utils.bunch.Bunch = None) -> None:\n", " \"\"\"Set the layer's filter_categories property based on widget selections.\"\"\"\n", " bedrooms = bedrooms_select.value\n", " bathrooms = bathrooms_select.value\n", @@ -186,27 +191,43 @@ " if len(bathrooms) == 0:\n", " bathrooms = unique_bathrooms_values\n", " home_sale_layer.filter_categories = [bedrooms, bathrooms]\n", + "\n", + "\n", "bedrooms_select.observe(on_select_change, \"value\")\n", "bathrooms_select.observe(on_select_change, \"value\")\n", "\n", "clear_bedrooms_button = Button(description=\"Clear Bedrooms\")\n", - "def clear_bedrooms(_:Button)->None:\n", + "\n", + "\n", + "def clear_bedrooms(_: Button) -> None:\n", " bedrooms_select.value = []\n", + "\n", + "\n", "clear_bedrooms_button.on_click(clear_bedrooms)\n", "\n", "clear_bathrooms_button = Button(description=\"Clear Bathrooms\")\n", - "def clear_bathrooms(_:Button)->None:\n", + "\n", + "\n", + "def clear_bathrooms(_: Button) -> None:\n", " bathrooms_select.value = []\n", + "\n", + "\n", "clear_bathrooms_button.on_click(clear_bathrooms)\n", "\n", - "home_sale_map = Map(layers=[home_sale_layer], basemap=lonboard.basemap.MaplibreBasemap())\n", - "on_select_change() # fire the function once to make initially set the layer's filter_categories, and display all points\n", + "home_sale_map = Map(\n", + " layers=[home_sale_layer], basemap=lonboard.basemap.MaplibreBasemap()\n", + ")\n", + "on_select_change() # fire the function once to make initially set the layer's filter_categories, and display all points\n", "\n", "display(home_sale_map)\n", - "display(HBox([\n", - " VBox([bedrooms_select, clear_bedrooms_button]),\n", - " VBox([bathrooms_select, clear_bathrooms_button]),\n", - "]))" + "display(\n", + " HBox(\n", + " [\n", + " VBox([bedrooms_select, clear_bedrooms_button]),\n", + " VBox([bathrooms_select, clear_bathrooms_button]),\n", + " ]\n", + " )\n", + ")" ] } ], From 9982c6347be30052ab5bb7df1d29fa31aeb64844 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sat, 8 Nov 2025 18:37:11 -0600 Subject: [PATCH 20/22] lint --- examples/data-filter-extension-categorical.ipynb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/data-filter-extension-categorical.ipynb b/examples/data-filter-extension-categorical.ipynb index 1f3b2e97..f5031596 100644 --- a/examples/data-filter-extension-categorical.ipynb +++ b/examples/data-filter-extension-categorical.ipynb @@ -178,7 +178,7 @@ "\n", "bedrooms_select = SelectMultiple(description=\"Bedrooms\", options=unique_bedrooms_values)\n", "bathrooms_select = SelectMultiple(\n", - " description=\"Bathrooms\", options=unique_bathrooms_values\n", + " description=\"Bathrooms\", options=unique_bathrooms_values,\n", ")\n", "\n", "\n", @@ -215,7 +215,7 @@ "clear_bathrooms_button.on_click(clear_bathrooms)\n", "\n", "home_sale_map = Map(\n", - " layers=[home_sale_layer], basemap=lonboard.basemap.MaplibreBasemap()\n", + " layers=[home_sale_layer], basemap=lonboard.basemap.MaplibreBasemap(),\n", ")\n", "on_select_change() # fire the function once to make initially set the layer's filter_categories, and display all points\n", "\n", @@ -225,8 +225,8 @@ " [\n", " VBox([bedrooms_select, clear_bedrooms_button]),\n", " VBox([bathrooms_select, clear_bathrooms_button]),\n", - " ]\n", - " )\n", + " ],\n", + " ),\n", ")" ] } From 5fde03eff95e7170f48de488dc858702627beba5 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sat, 8 Nov 2025 18:43:19 -0600 Subject: [PATCH 21/22] format again --- examples/data-filter-extension-categorical.ipynb | 6 ++++-- mkdocs.yml | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/data-filter-extension-categorical.ipynb b/examples/data-filter-extension-categorical.ipynb index f5031596..db6eefa0 100644 --- a/examples/data-filter-extension-categorical.ipynb +++ b/examples/data-filter-extension-categorical.ipynb @@ -178,7 +178,8 @@ "\n", "bedrooms_select = SelectMultiple(description=\"Bedrooms\", options=unique_bedrooms_values)\n", "bathrooms_select = SelectMultiple(\n", - " description=\"Bathrooms\", options=unique_bathrooms_values,\n", + " description=\"Bathrooms\",\n", + " options=unique_bathrooms_values,\n", ")\n", "\n", "\n", @@ -215,7 +216,8 @@ "clear_bathrooms_button.on_click(clear_bathrooms)\n", "\n", "home_sale_map = Map(\n", - " layers=[home_sale_layer], basemap=lonboard.basemap.MaplibreBasemap(),\n", + " layers=[home_sale_layer],\n", + " basemap=lonboard.basemap.MaplibreBasemap(),\n", ")\n", "on_select_change() # fire the function once to make initially set the layer's filter_categories, and display all points\n", "\n", diff --git a/mkdocs.yml b/mkdocs.yml index 4cfad63b..f00d19c6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -47,6 +47,7 @@ nav: - examples/kontur_pop.ipynb - examples/migration.ipynb - examples/data-filter-extension.ipynb + - examples/data-filter-extension-categorical.ipynb - examples/column-layer.ipynb - examples/interleaved-labels.ipynb - examples/linked-maps.ipynb From 607c926f828bf30363a993ae33b63388dd8befa9 Mon Sep 17 00:00:00 2001 From: ATL2001 <35881864+ATL2001@users.noreply.github.com> Date: Sat, 8 Nov 2025 18:43:42 -0600 Subject: [PATCH 22/22] typo --- examples/data-filter-extension-categorical.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/data-filter-extension-categorical.ipynb b/examples/data-filter-extension-categorical.ipynb index db6eefa0..1e6d7be1 100644 --- a/examples/data-filter-extension-categorical.ipynb +++ b/examples/data-filter-extension-categorical.ipynb @@ -219,7 +219,7 @@ " layers=[home_sale_layer],\n", " basemap=lonboard.basemap.MaplibreBasemap(),\n", ")\n", - "on_select_change() # fire the function once to make initially set the layer's filter_categories, and display all points\n", + "on_select_change() # fire the function once to initially set the layer's filter_categories, and display all points\n", "\n", "display(home_sale_map)\n", "display(\n",