Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
f400cda
Add side selector to data-filter test app
felixpalmer May 18, 2023
b8d52d7
Pass through categories to layer
felixpalmer May 18, 2023
e1761df
Hacky version of float based filter
felixpalmer May 18, 2023
89c5a1b
Category indirection
felixpalmer May 18, 2023
d4384b4
Support string labels
felixpalmer May 18, 2023
b56b41f
Shape name labels
felixpalmer May 18, 2023
3bb112c
Filtering based on bit mask
felixpalmer May 18, 2023
1fc4506
Fewer points
felixpalmer May 18, 2023
57f27a0
Tidy
felixpalmer May 18, 2023
cb17634
Handle instanced geometry
felixpalmer May 19, 2023
de6b99c
Use ivec4 to represent bitmask
felixpalmer May 19, 2023
e8b1dbd
Support up to 128 categories
felixpalmer May 19, 2023
fec02f8
Refactor category filtering into setValue()
felixpalmer May 19, 2023
fc45b28
Prepare for categorySize
felixpalmer May 19, 2023
ca70323
Better box
felixpalmer May 22, 2023
814908e
Prepare for 2D support
felixpalmer May 22, 2023
90c58bf
Correct offset calculation
felixpalmer May 22, 2023
cf50efb
Odd shape filter
felixpalmer May 22, 2023
56da6e3
Odd even filter
felixpalmer May 22, 2023
12458fd
Tidy and vectorize shader
felixpalmer May 22, 2023
acdba21
Generalize using DATACATEGORY_TYPE
felixpalmer May 22, 2023
b0ed6aa
Comparison using any()
felixpalmer May 22, 2023
ddd8686
Handle both float/vec2 filters in shader
felixpalmer May 22, 2023
e609c2b
Filter by colors also
felixpalmer May 22, 2023
27412dc
Tidy
felixpalmer May 22, 2023
12d5b54
Add sizes
felixpalmer May 23, 2023
85bf4e1
Don't update categories on every frame
felixpalmer May 23, 2023
3ccc11d
Add category test cases
felixpalmer May 23, 2023
5277a97
Start on category reset
felixpalmer May 23, 2023
ec7dbc3
Clear categoryMap when data changes
felixpalmer May 23, 2023
094feaf
Use call() rather than bind()
felixpalmer May 23, 2023
9c5895a
getPointRadius
felixpalmer May 23, 2023
15ca037
Per-channel key mapping
felixpalmer May 23, 2023
e21a0d2
Tidy
felixpalmer May 23, 2023
df6e26a
Counts working
felixpalmer May 23, 2023
773f04b
Tidy
felixpalmer May 24, 2023
5aafa93
Sanity check for props
felixpalmer May 24, 2023
fbe40d4
Render tests for category filtering
felixpalmer May 24, 2023
1cc4007
Handle out of range category
felixpalmer May 24, 2023
a122db6
Support filter_enabled for category filter
felixpalmer May 24, 2023
e6f2d3a
Tidy
felixpalmer May 24, 2023
2a07836
Rename prop
felixpalmer May 25, 2023
4ee73a3
Docs on categories in data filter
felixpalmer May 25, 2023
4afe168
Add notes on optional
felixpalmer May 25, 2023
318b8cf
Animation control
felixpalmer May 25, 2023
7a48643
Handle case of binary attributes
felixpalmer May 26, 2023
dc1d53f
Initialize test correctly
felixpalmer May 26, 2023
f73a919
Merge branch 'master' into felix/data-filter-category
felixpalmer Feb 9, 2024
1e94235
Type fix
felixpalmer Feb 9, 2024
cec57c9
Better type for FilterCategory
felixpalmer Feb 21, 2024
2978069
Tidy
felixpalmer Feb 21, 2024
0c97897
Rename to tsx
felixpalmer Feb 21, 2024
2628519
Convert to Typescript
felixpalmer Feb 21, 2024
0344ca7
size defaults
felixpalmer Feb 21, 2024
11cb8c5
Merge branch 'master' into felix/data-filter-category
felixpalmer Feb 21, 2024
2dfd6e3
Lint
felixpalmer Feb 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/api-reference/extensions/data-filter-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ new DataFilterExtension({filterSize, fp64});
```

* `filterSize` (Number) - 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. Default `1`.
* `categorySize` (Number) - 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. Default `1`.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can this be auto-detected?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Perhaps in a followup PR. But in that case filterSize should also support it. Part of me thinks it is good to have it be explicit as then the user intention is clear. For auto detection, should we use filterCategories or the getFilterCategory accessor? What if they don't match?

* `fp64` (Boolean) - if `true`, use 64-bit precision instead of 32-bit. Default `false`. See the "remarks" section below for use cases and limitations.
* `countItems` (Boolean) - if `true`, reports the number of filtered objects with the `onFilteredItemsChange` callback. Default `false`.

Expand Down Expand Up @@ -153,6 +154,63 @@ Format:
* If `filterSize` is `1`: `[softMin, softMax]`
* If `filterSize` is `2` to `4`: `[[softMin0, softMax0], [softMin1, softMax1], ...]` for each filtered property, respectively.

##### `getFilterCategory` ([Function](../../developer-guide/using-layers.md#accessors), optional) {#getfiltercategory}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: This name didn't click immediately for me. I would probably have gone with getDataFilterCategory (especially as we now have multiple "filter" extensions) but I guess the naming convention is somewhat fixed by now.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't love it either, I was trying to stay "consistent" with getFilterValue. I think we should stick with this or make both accessors consistent, e.g. use getDataFilterValue & getDataFilterCategory. This would be a breaking change, and I'm not sure it is worth it


* Default: `0`

Called to retrieve the category for each object that it will be filtered by. Returns either a category as a number or string (if `categorySize: 1`) or an array.

For example, consider data in the following format:

```json
[
{"industry": "retail", "coordinates": [-122.45, 37.78], "size": 10},
...
]
```

To filter by industry:

```js
new ScatterplotLayer({
data,
getPosition: d => d.coordinates,
getFilterCategory: d => d.industry,
filterCategories: ['retail', 'health'],
extensions: [new DataFilterExtension({categorySize: 1})]
})
```

To filter by both industry and size:

```js
new ScatterplotLayer({
data,
getPosition: d => d.coordinates,
getFilterCategory: d => [d.industry, d.size],
filterCategories: [['retail', 'health'], [10, 20, 50]],
extensions: [new DataFilterExtension({categorySize: 2})]
})
```

##### `filterCategories` (Array, optional) {#filtercategories}

* Default: `[0]`

The list of categories that should be rendered. If an object's filtered category is in the list, the object will be rendered; otherwise it will be hidden. This prop can be updated on user input or animation with very little cost.

Format:

* If `categorySize` is `1`: `['category1', 'category2']`
* If `categorySize` is `2` to `4`: `[['category1', 'category2', ...], ['category3', ...], ...]` for each filtered property, respectively.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could we avoid the overloading and always required nested array, and just use firm typescript typing?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Could it be a map with column names?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Again, I prioritized consistency with existing API here


The maximum number of supported is determined by the `categorySize`:

- If `categorySize` is `1`: 128 categories
- If `categorySize` is `2`: 64 categories per dimension
- If `categorySize` is `3` or `4`: 32 categories per dimension

If this value is exceeded any categories beyond the limit will be ignored.

##### `filterTransformSize` (Boolean, optional) {#filtertransformsize}

Expand Down
157 changes: 133 additions & 24 deletions modules/extensions/src/data-filter/data-filter-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,36 @@ import {GL} from '@luma.gl/constants';
import type {Framebuffer} from '@luma.gl/core';
import type {Model} from '@luma.gl/engine';
import type {Layer, LayerContext, Accessor, UpdateParameters} from '@deck.gl/core';
import {LayerExtension} from '@deck.gl/core';
import {_deepEqual as deepEqual, LayerExtension, log} from '@deck.gl/core';
import {shaderModule, shaderModule64} from './shader-module';
import * as aggregator from './aggregator';

const defaultProps = {
getFilterValue: {type: 'accessor', value: 0},
getFilterCategory: {type: 'accessor', value: 0},
onFilteredItemsChange: {type: 'function', value: null, optional: true},

filterEnabled: true,
filterRange: [-1, 1],
filterSoftRange: null,
filterCategories: [0],
filterTransformSize: true,
filterTransformColor: true
};

type FilterCategory = number | string;

export type DataFilterExtensionProps<DataT = any> = {
/**
* Accessor to retrieve the value for each object that it will be filtered by.
* Returns either a number (if `filterSize: 1`) or an array of numbers.
*/
getFilterValue?: Accessor<DataT, number | number[]>;
/**
* Accessor to retrieve the category (`number | string`) for each object that it will be filtered by.
* Returns either a single category (if `filterSize: 1`) or an array of categories.
*/
getFilterCategory?: Accessor<DataT, FilterCategory | FilterCategory[]>;
/**
* Enable/disable the data filter. If the data filter is disabled, all objects are rendered.
* @default true
Expand Down Expand Up @@ -70,6 +79,11 @@ export type DataFilterExtensionProps<DataT = any> = {
* @default true
*/
filterTransformColor?: boolean;
/**
* The categories which define whether an object should be rendered.
* @default []
*/
filterCategories: FilterCategory[] | FilterCategory[][];
/**
* Only called if the `countItems` option is enabled.
*/
Expand All @@ -82,21 +96,26 @@ export type DataFilterExtensionProps<DataT = any> = {
};

type DataFilterExtensionOptions = {
/**
* 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.
* @default 1
*/
categorySize?: 1 | 2 | 3 | 4;
/**
* 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.
* @default 1
*/
filterSize: number;
filterSize?: 1 | 2 | 3 | 4;
/**
* Use 64-bit precision instead of 32-bit.
* @default false
*/
fp64: boolean;
fp64?: boolean;
/**
* If `true`, reports the number of filtered objects with the `onFilteredItemsChange` callback.
* @default `false`.
*/
countItems: boolean;
countItems?: boolean;
};

const DATA_TYPE_FROM_SIZE = {
Expand All @@ -111,37 +130,29 @@ export default class DataFilterExtension extends LayerExtension<DataFilterExtens
static defaultProps = defaultProps;
static extensionName = 'DataFilterExtension';

constructor({
filterSize = 1,
fp64 = false,
countItems = false
}: Partial<DataFilterExtensionOptions> = {}) {
if (!DATA_TYPE_FROM_SIZE[filterSize]) {
throw new Error('filterSize out of range');
}

super({filterSize, fp64, countItems});
}

getShaders(this: Layer<DataFilterExtensionProps>, extension: this): any {
const {filterSize, fp64} = extension.opts;
const {categorySize, filterSize, fp64} = extension.opts;

return {
modules: [fp64 ? shaderModule64 : shaderModule],
defines: {
DATAFILTER_TYPE: DATA_TYPE_FROM_SIZE[filterSize],
DATACATEGORY_TYPE: DATA_TYPE_FROM_SIZE[categorySize || 1],
DATACATEGORY_CHANNELS: categorySize,
DATAFILTER_TYPE: DATA_TYPE_FROM_SIZE[filterSize || 1],
DATAFILTER_DOUBLE: Boolean(fp64)
}
};
}

initializeState(this: Layer<DataFilterExtensionProps>, context: LayerContext, extension: this) {
const attributeManager = this.getAttributeManager();
const {categorySize, filterSize, fp64} = extension.opts;

if (attributeManager) {
attributeManager.add({
filterValues: {
size: extension.opts.filterSize,
type: extension.opts.fp64 ? GL.DOUBLE : GL.FLOAT,
size: filterSize,
type: fp64 ? GL.DOUBLE : GL.FLOAT,
accessor: 'getFilterValue',
shaderAttributes: {
filterValues: {
Expand All @@ -151,6 +162,23 @@ export default class DataFilterExtension extends LayerExtension<DataFilterExtens
divisor: 1
}
}
},
filterCategoryValues: {
size: categorySize,
type: GL.FLOAT,
accessor: 'getFilterCategory',
transform:
categorySize === 1
? d => extension._getCategoryKey.call(this, d, 0)
: d => d.map((x, i) => extension._getCategoryKey.call(this, x, i)),
shaderAttributes: {
filterCategoryValues: {
divisor: 0
},
instanceFilterCategoryValues: {
divisor: 1
}
}
}
});
}
Expand Down Expand Up @@ -194,31 +222,60 @@ export default class DataFilterExtension extends LayerExtension<DataFilterExtens

updateState(
this: Layer<DataFilterExtensionProps>,
{props, oldProps}: UpdateParameters<Layer<DataFilterExtensionProps>>
{props, oldProps, changeFlags}: UpdateParameters<Layer<DataFilterExtensionProps>>,
extension: this
) {
const attributeManager = this.getAttributeManager();
const {categorySize} = extension.opts;
if (this.state.filterModel) {
const attributeManager = this.getAttributeManager();
const filterNeedsUpdate =
// attributeManager must be defined for filterModel to be set
attributeManager!.attributes.filterValues.needsUpdate() ||
attributeManager!.attributes.filterCategoryValues?.needsUpdate() ||
props.filterEnabled !== oldProps.filterEnabled ||
props.filterRange !== oldProps.filterRange ||
props.filterSoftRange !== oldProps.filterSoftRange;
props.filterSoftRange !== oldProps.filterSoftRange ||
props.filterCategories !== oldProps.filterCategories;
if (filterNeedsUpdate) {
this.setState({filterNeedsUpdate});
}
}
if (attributeManager?.attributes.filterCategoryValues) {
// Update bitmask if accessor or selected categories has changed
const categoryBitMaskNeedsUpdate =
attributeManager.attributes.filterCategoryValues.needsUpdate() ||
!deepEqual(props.filterCategories, oldProps.filterCategories, 2);
if (categoryBitMaskNeedsUpdate) {
this.setState({categoryBitMaskNeedsUpdate});
}

// Need to recreate category map if categorySize has changed
const resetCategories = changeFlags.dataChanged;
if (resetCategories) {
this.setState({
categoryMap: Array(categorySize)
.fill(0)
.map(() => ({}))
});
attributeManager.attributes.filterCategoryValues.setNeedsUpdate('categoryMap');
}
}
}

draw(this: Layer<DataFilterExtensionProps>, params: any, extension: this) {
const filterFBO = this.state.filterFBO as Framebuffer;
const filterModel = this.state.filterModel as Model;
const filterNeedsUpdate = this.state.filterNeedsUpdate as boolean;
const categoryBitMaskNeedsUpdate = this.state.categoryBitMaskNeedsUpdate as boolean;

const {onFilteredItemsChange} = this.props;

if (categoryBitMaskNeedsUpdate) {
extension._updateCategoryBitMask.call(this, params, extension);
}
if (filterNeedsUpdate && onFilteredItemsChange && filterModel) {
const {
attributes: {filterValues, filterIndices}
attributes: {filterValues, filterCategoryValues, filterIndices}
} = this.getAttributeManager()!;
filterModel.setVertexCount(this.getNumInstances());

Expand All @@ -228,8 +285,10 @@ export default class DataFilterExtension extends LayerExtension<DataFilterExtens
// @ts-expect-error filterValue and filterIndices should always have buffer value
filterModel.setAttributes({
...filterValues.getValue(),
...filterCategoryValues?.getValue(),
...filterIndices?.getValue()
});
filterModel.setUniforms(params.uniforms);
filterModel.device.withParametersWebGL(
{
framebuffer: filterFBO,
Expand Down Expand Up @@ -260,4 +319,54 @@ export default class DataFilterExtension extends LayerExtension<DataFilterExtens
filterFBO?.destroy();
filterModel?.destroy();
}

/**
* Updates the bitmask used on the GPU to perform the filter based on the
* `filterCategories` prop. The mapping between categories and bit in the bitmask
* is performed by `_getCategoryKey()`
*/
_updateCategoryBitMask(
this: Layer<DataFilterExtensionProps>,
params: any,
extension: this
): void {
const {categorySize} = extension.opts;
const {filterCategories} = this.props;
const categoryBitMask = new Uint32Array([0, 0, 0, 0]);
const categoryFilters = (
categorySize === 1 ? [filterCategories] : filterCategories
) as FilterCategory[][];
const maxCategories = categorySize === 1 ? 128 : categorySize === 2 ? 64 : 32;
for (let c = 0; c < categoryFilters.length; c++) {
const categoryFilter = categoryFilters[c];
for (const category of categoryFilter) {
const key = extension._getCategoryKey.call(this, category, c);
if (key < maxCategories) {
const channel = c * (maxCategories / 32) + Math.floor(key / 32);
categoryBitMask[channel] += Math.pow(2, key % 32); // 1 << key fails for key > 30
} else {
log.warn(`Exceeded maximum number of categories (${maxCategories})`)();
}
}
}
/* eslint-disable-next-line camelcase */
params.uniforms.filter_categoryBitMask = categoryBitMask;
this.state.categoryBitMaskNeedsUpdate = false;
}

/**
* Returns an index of bit in the bitmask for a given category. If the category has
* not yet been assigned a bit, a new one is assigned.
*/
_getCategoryKey(
this: Layer<DataFilterExtensionProps>,
category: FilterCategory,
channel: number
) {
const categoryMap = (this.state.categoryMap as Record<FilterCategory, number>[])[channel];
if (!(category in categoryMap)) {
categoryMap[category] = Object.keys(categoryMap).length;
}
return categoryMap[category];
}
}
Loading