diff --git a/src/layers/src/base-layer.ts b/src/layers/src/base-layer.ts index 930da58a11..b6ae33775e 100644 --- a/src/layers/src/base-layer.ts +++ b/src/layers/src/base-layer.ts @@ -1299,11 +1299,12 @@ class Layer implements KeplerLayer { this.meta = {...this.meta, ...meta}; } - getDataUpdateTriggers({filteredIndex, id, dataContainer}: KeplerTable): any { + getDataUpdateTriggers({filteredIndex, filteredIndexByLayer, id, dataContainer}: KeplerTable): any { const {columns} = this.config; + const layerFilteredIndex = filteredIndexByLayer?.[this.id] ?? filteredIndex; return { - getData: {datasetId: id, dataContainer, columns, filteredIndex}, + getData: {datasetId: id, dataContainer, columns, filteredIndex: layerFilteredIndex}, getMeta: {datasetId: id, dataContainer, columns}, ...(this.config.textLabel || []).reduce( (accu, tl, i) => ({ @@ -1322,12 +1323,18 @@ class Layer implements KeplerLayer { const layerDataset = datasets[this.config.dataId]; const {dataContainer} = layerDataset; - const getPosition = this.getPositionAccessor(dataContainer, layerDataset); + // Use per-layer polygon-filtered index if available + const effectiveDataset = + layerDataset.filteredIndexByLayer?.[this.id] != null + ? {...layerDataset, filteredIndex: layerDataset.filteredIndexByLayer[this.id]} + : layerDataset; + + const getPosition = this.getPositionAccessor(dataContainer, effectiveDataset); const dataUpdateTriggers = this.getDataUpdateTriggers(layerDataset); const triggerChanged = this.getChangedTriggers(dataUpdateTriggers); if (triggerChanged && (triggerChanged.getMeta || triggerChanged.getData)) { - this.updateLayerMeta(layerDataset, getPosition); + this.updateLayerMeta(effectiveDataset, getPosition); // reset filteredItemCount this.filteredItemCount = {}; @@ -1339,7 +1346,7 @@ class Layer implements KeplerLayer { // same data data = oldLayerData.data; } else { - data = this.calculateDataAttribute(layerDataset, getPosition); + data = this.calculateDataAttribute(effectiveDataset, getPosition); } return {data, triggerChanged}; diff --git a/src/layers/src/mapboxgl-layer.ts b/src/layers/src/mapboxgl-layer.ts index d1b01841c4..aa438b5fc6 100644 --- a/src/layers/src/mapboxgl-layer.ts +++ b/src/layers/src/mapboxgl-layer.ts @@ -82,8 +82,9 @@ class MapboxLayerGL extends Layer { return Array.isArray(filter) && filter.length; } - getDataUpdateTriggers({filteredIndex, gpuFilter, id}: KeplerTable): any { + getDataUpdateTriggers({filteredIndex, filteredIndexByLayer, gpuFilter, id}: KeplerTable): any { const {columns} = this.config; + const layerFilteredIndex = filteredIndexByLayer?.[this.id] ?? filteredIndex; const visualChannelFields = Object.values(this.visualChannels).reduce( (accu, v) => ({ @@ -97,7 +98,7 @@ class MapboxLayerGL extends Layer { getData: { datasetId: id, columns, - filteredIndex, + filteredIndex: layerFilteredIndex, ...visualChannelFields, ...gpuFilter.filterValueUpdateTriggers }, diff --git a/src/table/src/kepler-table.ts b/src/table/src/kepler-table.ts index 9ffde6a3e7..4103090560 100644 --- a/src/table/src/kepler-table.ts +++ b/src/table/src/kepler-table.ts @@ -9,7 +9,8 @@ import { SORT_ORDER, ALL_FIELD_TYPES, ALTITUDE_FIELDS, - SCALE_TYPES + SCALE_TYPES, + FILTER_TYPES } from '@kepler.gl/constants'; import { RGBColor, @@ -39,6 +40,8 @@ import { getFilterFunction, getFilterProps, getFilterRecord, + getPolygonFilterFunctor, + isValidFilterValue, getNumericFieldDomain, getTimestampFieldDomain, getLinearDomain, @@ -118,6 +121,67 @@ export function maybeToDate( return Array.isArray(d) ? d[fieldIdx] : dc.valueAt(d.index, fieldIdx); } +/** + * Compute per-layer filtered indices for polygon filters. + * Polygon filters are layer-specific: they should only affect the layers listed in filter.layerId. + * For each layer on this dataset, compute a filtered index that applies only the polygon filters + * targeting that specific layer (using OR logic when multiple layers on the same dataset are selected). + */ +function computePolygonFilteredIndexByLayer( + filters: Filter[], + layers: Layer[], + dataId: string, + dataContainer: DataContainerInterface, + baseFilteredIndex: number[] +): Record { + const polygonFilters = filters.filter( + f => + f.type === FILTER_TYPES.polygon && + f.dataId.includes(dataId) && + f.enabled !== false && + isValidFilterValue(f.type, f.value) + ); + + if (!polygonFilters.length) { + return {}; + } + + const layersOnDataset = layers.filter(l => l.config?.dataId === dataId); + const result: Record = {}; + + for (const layer of layersOnDataset) { + // For each polygon filter, check if this layer is targeted + const applicableFilters = polygonFilters.filter( + f => f.layerId && f.layerId.includes(layer.id) + ); + + if (!applicableFilters.length) { + // This layer is not targeted by any polygon filter - use base index + continue; + } + + // Build polygon filter functors for this layer + const filterFunctors = applicableFilters.map(filter => + getPolygonFilterFunctor(layer, filter, dataContainer) + ); + + // Filter the base filtered index: a row passes if it passes ALL polygon filters + // (each polygon filter already uses this layer's position accessor) + const layerFilteredIndex: number[] = []; + const filterContext = {index: -1}; + for (let i = 0; i < baseFilteredIndex.length; i++) { + filterContext.index = baseFilteredIndex[i]; + if (filterFunctors.every(fn => fn(filterContext))) { + layerFilteredIndex.push(baseFilteredIndex[i]); + } + } + + result[layer.id] = layerFilteredIndex; + } + + return result; +} + class KeplerTable { readonly id: string; @@ -134,6 +198,7 @@ class KeplerTable { filteredIndex: number[] = []; filteredIdxCPU?: number[]; filteredIndexForDomain: number[] = []; + filteredIndexByLayer: Record = {}; fieldPairs: FieldPair[] = []; gpuFilter: GpuFilter; filterRecord?: FilterRecord; @@ -359,6 +424,7 @@ class KeplerTable { if (!filters.length) { this.filteredIndex = this.allIndexes; this.filteredIndexForDomain = this.allIndexes; + this.filteredIndexByLayer = {}; return this; } @@ -393,6 +459,15 @@ class KeplerTable { this.filteredIndexForDomain = filterResult.filteredIndexForDomain || this.filteredIndexForDomain; + // Compute per-layer filtered indices for polygon filters + this.filteredIndexByLayer = computePolygonFilteredIndexByLayer( + filters, + layers, + dataId, + dataContainer, + this.filteredIndex + ); + return this; } diff --git a/src/utils/src/filter-utils.ts b/src/utils/src/filter-utils.ts index f63665d62c..4738c54dda 100644 --- a/src/utils/src/filter-utils.ts +++ b/src/utils/src/filter-utils.ts @@ -586,6 +586,12 @@ export function getFilterRecord( filters.forEach(f => { if (isValidFilterValue(f.type, f.value) && toArray(f.dataId).includes(dataId)) { + // Polygon filters are layer-specific and handled per-layer, not at the dataset level + if (f.type === FILTER_TYPES.polygon) { + filterRecord.fixedDomain.push(f); + return; + } + (f.fixedDomain || opt.ignoreDomain ? filterRecord.fixedDomain : filterRecord.dynamicDomain diff --git a/test/helpers/comparison-utils.js b/test/helpers/comparison-utils.js index d1d6a729b5..def7ac2235 100644 --- a/test/helpers/comparison-utils.js +++ b/test/helpers/comparison-utils.js @@ -325,7 +325,20 @@ export function cmpColumns(t, expectedColumns, actualColumns, layerName) { export function cmpDataset(t, expectedDataset, actualDataset, opt = {}) { assertDatasetIsTable(t, actualDataset); - cmpObjectKeys(t, expectedDataset, actualDataset, `dataset:${expectedDataset.id}`); + // filteredIndexByLayer is an internal optimization property, skip in key comparison if not in expected + const actualKeysForComparison = Object.keys(actualDataset).filter( + key => key !== 'filteredIndexByLayer' || key in expectedDataset + ); + const expectedKeysForComparison = Object.keys(expectedDataset); + t.deepEqual( + actualKeysForComparison + .filter(key => actualDataset[key] !== undefined) + .sort(), + expectedKeysForComparison + .filter(key => expectedDataset[key] !== undefined) + .sort(), + `dataset:${expectedDataset.id} should have same keys` + ); // test everything except auto generated color Object.keys(actualDataset) @@ -365,6 +378,15 @@ export function cmpDataset(t, expectedDataset, actualDataset, opt = {}) { ); }); break; + case 'filteredIndexByLayer': + if (key in expectedDataset) { + t.deepEqual( + actualDataset[key], + expectedDataset[key], + `dataset.${expectedDataset.id}.${key} should be correct` + ); + } + break; default: if (key !== 'color' || opt.color) { t.deepEqual( diff --git a/test/node/reducers/vis-state-test.js b/test/node/reducers/vis-state-test.js index 994659e9a2..43911733b4 100644 --- a/test/node/reducers/vis-state-test.js +++ b/test/node/reducers/vis-state-test.js @@ -5026,7 +5026,7 @@ test('#visStateReducer -> POLYGON: Create polygon filter', t => { t.equal(newReducer.layerData[0].data.length, 2, 'Layer Point 1 should only show 2 points'); - t.equal(newReducer.layerData[1].data.length, 2, 'Layer Point 2 should only show 2 points'); + t.equal(newReducer.layerData[1].data.length, 4, 'Layer Point 2 should show all 4 points (not targeted by filter)'); const filterFeature = newReducer.filters[0].value; @@ -5040,9 +5040,9 @@ test('#visStateReducer -> POLYGON: Create polygon filter', t => { t.equal(newReducer.filters[0].layerId.length, 2, 'Should have two values in filter.layerId'); - t.equal(newReducer.layerData[0].data.length, 0, 'Layer Point 1 should show 0 points'); + t.equal(newReducer.layerData[0].data.length, 2, 'Layer Point 1 should show 2 points (filtered by its own position)'); - t.equal(newReducer.layerData[1].data.length, 0, 'Layer Point 2 show show 0 points'); + t.equal(newReducer.layerData[1].data.length, 0, 'Layer Point 2 should show 0 points (end positions are outside polygon)'); // Adding a new dataset - creates extra 4 layers newReducer = applyActions(reducer, newReducer, [ @@ -5079,15 +5079,15 @@ test('#visStateReducer -> POLYGON: Create polygon filter', t => { t.equal( newReducer.layerData[0].data.length, 2, - 'Layer Point 1 show 2 points because we removed layer 2' + 'Layer Point 1 show 2 points because it is still filtered' ); t.equal(newReducer.layerData[4].data.length, 2, 'Layer Point 5 should 2 points because filtered'); t.equal( - newReducer.layerData[2].data.length, - 2, - 'Layer Point 2 should still show 2 filters because layer 1 is still filtered' + newReducer.layerData[1].data.length, + 4, + 'Layer Point 2 should show full data because it was removed from filter' ); t.end(); @@ -5250,8 +5250,12 @@ test('#visStateReducer -> POLYGON: Toggle filter feature', t => { ); t.deepEqual( newReducer.datasets.puppy.filteredIndex, - [0, 2], - 'The polygon filter should be applied' + [0, 1, 2, 3], + 'The dataset filteredIndex should not be affected by polygon filters' + ); + t.ok( + newReducer.datasets.puppy.filteredIndexByLayer[newReducer.layers[0].id], + 'Should have per-layer polygon filtered index' ); newReducer = reducer(newReducer, VisStateActions.toggleFilterFeature(0)); @@ -5412,9 +5416,9 @@ test('#visStateReducer -> POLYGON: setPolygonFilterLayer: H3', t => { const expectedFilteredIndex = [1, 3, 5, 8]; t.deepEqual( - newState.datasets['190vdll3di'].filteredIndex, + newState.datasets['190vdll3di'].filteredIndexByLayer[newState.layers[0].id], expectedFilteredIndex, - 'should filter data based on h3 layer' + 'should have per-layer polygon filtered index for h3 layer' ); t.deepEqual( newState.layerData[0].data.map(d => d.index),