diff --git a/src/layers/src/base-layer.ts b/src/layers/src/base-layer.ts index c46bfa1016..cdb23043c2 100644 --- a/src/layers/src/base-layer.ts +++ b/src/layers/src/base-layer.ts @@ -45,7 +45,7 @@ import { isArrowTable } from '@kepler.gl/utils'; import {generateHashId, toArray, notNullorUndefined} from '@kepler.gl/common-utils'; -import {Datasets, GpuFilter, KeplerTable} from '@kepler.gl/table'; +import {Datasets, GpuFilter, KeplerTable, copyTableAndUpdate} from '@kepler.gl/table'; import { AggregatedBin, ColorRange, @@ -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,19 @@ 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 layerFilteredIndex = layerDataset.filteredIndexByLayer?.[this.id]; + const effectiveDataset = + layerFilteredIndex != null + ? copyTableAndUpdate(layerDataset, {filteredIndex: layerFilteredIndex}) + : 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 +1347,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..96fa4e0ec7 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,9 @@ import { getFilterFunction, getFilterProps, getFilterRecord, + getPolygonFilterFunctor, + isValidFilterValue, + mergePolygonLayerIndexes, getNumericFieldDomain, getTimestampFieldDomain, getLinearDomain, @@ -118,6 +122,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. Multiple polygon filters targeting the same layer are ANDed. + */ +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 +199,7 @@ class KeplerTable { filteredIndex: number[] = []; filteredIdxCPU?: number[]; filteredIndexForDomain: number[] = []; + filteredIndexByLayer: Record = {}; fieldPairs: FieldPair[] = []; gpuFilter: GpuFilter; filterRecord?: FilterRecord; @@ -230,6 +296,7 @@ class KeplerTable { this.allIndexes = allIndexes; this.filteredIndex = allIndexes; this.filteredIndexForDomain = allIndexes; + this.filteredIndexByLayer = {}; this.fieldPairs = findPointFieldPairs(fields); // @ts-expect-error Make sure that fields satisfies F extends Field this.fields = fields; @@ -246,6 +313,7 @@ class KeplerTable { this.allIndexes = this.dataContainer.getPlainIndex(); this.filteredIndex = this.allIndexes; this.filteredIndexForDomain = this.allIndexes; + this.filteredIndexByLayer = {}; return this; } @@ -359,6 +427,7 @@ class KeplerTable { if (!filters.length) { this.filteredIndex = this.allIndexes; this.filteredIndexForDomain = this.allIndexes; + this.filteredIndexByLayer = {}; return this; } @@ -393,6 +462,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; } @@ -414,23 +492,31 @@ class KeplerTable { return this; } + let baseIndex: number[]; + let indexByLayer: Record; + // no gpu filter if (!filters.find(f => f.gpu)) { - this.filteredIdxCPU = this.filteredIndex; + baseIndex = this.filteredIndex; + indexByLayer = this.filteredIndexByLayer; this.filterRecordCPU = getFilterRecord(this.id, filters, opt); - return this; - } + } else { + // make a copy for cpu filtering + const copied = copyTable(this); - // make a copy for cpu filtering - const copied = copyTable(this); + copied.filterRecord = this.filterRecordCPU; + copied.filteredIndex = this.filteredIdxCPU || []; - copied.filterRecord = this.filterRecordCPU; - copied.filteredIndex = this.filteredIdxCPU || []; + const filtered = copied.filterTable(filters, layers, opt); - const filtered = copied.filterTable(filters, layers, opt); + baseIndex = filtered.filteredIndex; + indexByLayer = filtered.filteredIndexByLayer; + this.filterRecordCPU = filtered.filterRecord; + } - this.filteredIdxCPU = filtered.filteredIndex; - this.filterRecordCPU = filtered.filterRecord; + // Polygon filters are applied per-layer (not in filteredIndex). For export, keep rows + // visible on any polygon-targeted layer. + this.filteredIdxCPU = mergePolygonLayerIndexes(baseIndex, indexByLayer); return this; } diff --git a/src/utils/src/filter-utils.ts b/src/utils/src/filter-utils.ts index d2cc3c2b83..22241f419e 100644 --- a/src/utils/src/filter-utils.ts +++ b/src/utils/src/filter-utils.ts @@ -601,6 +601,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/src/utils/src/index.ts b/src/utils/src/index.ts index edcb9e6264..46a180b755 100644 --- a/src/utils/src/index.ts +++ b/src/utils/src/index.ts @@ -40,6 +40,7 @@ export { histogramFromValues, histogramFromDomain, histogramFromOrdinal, + mergePolygonLayerIndexes, runGpuFilterForPlot, updateTimeFilterPlotType } from './plot'; diff --git a/src/utils/src/plot.ts b/src/utils/src/plot.ts index e313a11d29..bef9d3bded 100644 --- a/src/utils/src/plot.ts +++ b/src/utils/src/plot.ts @@ -40,6 +40,31 @@ import {KeplerTableModel} from './types'; // TODO kepler-table module isn't accessible from utils. Add compatible interface to types type Datasets = any; +/** + * Merge per-layer polygon-filtered indices into a dataset-level index. + * When any layers are polygon-filtered, take the union of their indices (a row is kept if it + * is visible on at least one targeted layer), then intersect with the base index. + * Used for filtered export and filter plots/histograms. + */ +export function mergePolygonLayerIndexes( + baseIndex: number[], + filteredIndexByLayer: Record = {} +): number[] { + const layerIndexes = Object.values(filteredIndexByLayer); + if (!layerIndexes.length) { + return baseIndex; + } + + const union = new Set(); + for (const indexes of layerIndexes) { + for (const idx of indexes) { + union.add(idx); + } + } + + return baseIndex.filter(i => union.has(i)); +} + /** * * @param thresholds @@ -242,8 +267,11 @@ export function runGpuFilterForPlot, L>( const { gpuFilter: {filterValueUpdateTriggers, filterRange, filterValueAccessor}, - filteredIndex + filteredIndex, + filteredIndexByLayer } = dataset; + // Polygon filters are per-layer; plots use the union of targeted layer indices + const plotFilteredIndex = mergePolygonLayerIndexes(filteredIndex, filteredIndexByLayer); const getFilterValue = filterValueAccessor(dataset.dataContainer)(); const allChannels = Object.keys(filterValueUpdateTriggers) @@ -251,7 +279,7 @@ export function runGpuFilterForPlot, L>( .filter(i => Object.values(filterValueUpdateTriggers)[i]); const skipAll = !allChannels.filter(i => !skipIndexes.includes(i)).length; if (skipAll) { - return filteredIndex; + return plotFilteredIndex; } const filterData = getFilterDataFunc( @@ -261,7 +289,7 @@ export function runGpuFilterForPlot, L>( skipIndexes ); - return filteredIndex.filter(filterData); + return plotFilteredIndex.filter(filterData); } function getSkipIndexes(dataset, filter) { diff --git a/src/utils/src/types.ts b/src/utils/src/types.ts index 17857ca5d6..43f8fe763d 100644 --- a/src/utils/src/types.ts +++ b/src/utils/src/types.ts @@ -25,4 +25,5 @@ export interface KeplerTableModel { ) => (d: any) => (number | number[])[]; }; filteredIndex: number[]; + filteredIndexByLayer?: Record; } diff --git a/test/fixtures/polygon.js b/test/fixtures/polygon.js index 7c48c3d257..cd720ebc89 100644 --- a/test/fixtures/polygon.js +++ b/test/fixtures/polygon.js @@ -179,6 +179,58 @@ export const mockPolygonData = { } }; +/** + * Closed rectangular polygon in GeoJSON [lng, lat] order. + */ +export function rectPolygonFeature(id, west, south, east, north) { + return { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [west, south], + [east, south], + [east, north], + [west, north], + [west, south] + ] + ] + }, + properties: { + renderType: 'Polygon', + isClosed: true + }, + id + }; +} + +/** + * One dataset with two lon/lat pairs (start + end). + * start (lng, lat): (10,10), (12,10), (14,10), (12,14) + * end (lng, lat): (30,10), (32,10), (34,10), (32,14) + * Row format: [start_lat, start_lng, end_lat, end_lng] + */ +export const mockDualLonLatRows = [ + [10, 10, 10, 30], + [10, 12, 10, 32], + [10, 14, 10, 34], + [14, 12, 14, 32] +]; + +// Covers start rows 0 and 1; none of the end points +export const mockStartRegionPolygon = rectPolygonFeature('start-region-poly', 9, 9, 13, 11); + +// Covers end rows 1 and 2; none of the start points +export const mockEndRegionPolygon = rectPolygonFeature('end-region-poly', 31, 9, 35, 11); + +// Overlaps mockStartRegionPolygon on start row 1 only +export const mockEastOverlapPolygon = rectPolygonFeature('east-overlap-poly', 11, 9, 15, 11); + +// Disjoint from mockWestOnlyPolygon: start row 0 vs start row 2 +export const mockWestOnlyPolygon = rectPolygonFeature('west-only-poly', 9, 9, 11, 11); +export const mockEastOnlyPolygon = rectPolygonFeature('east-only-poly', 13, 9, 15, 11); + export const mockPolygonFeature2 = { type: 'Feature', geometry: { 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..f5491b4427 100644 --- a/test/node/reducers/vis-state-test.js +++ b/test/node/reducers/vis-state-test.js @@ -73,7 +73,17 @@ import { } from 'test/fixtures/geojson'; import tripCsvData, {tripCsvDataInfo, expectedCoordinates} from 'test/fixtures/test-trip-csv-data'; import tripGeojson, {timeStampDomain, tripDataInfo} from 'test/fixtures/trip-geojson'; -import {mockPolygonFeature, mockPolygonFeature2, mockPolygonData} from 'test/fixtures/polygon'; +import { + mockPolygonFeature, + mockPolygonFeature2, + mockPolygonData, + mockDualLonLatRows, + mockStartRegionPolygon, + mockEndRegionPolygon, + mockEastOverlapPolygon, + mockWestOnlyPolygon, + mockEastOnlyPolygon +} from 'test/fixtures/polygon'; // test helpers import { @@ -5026,7 +5036,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 +5050,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 +5089,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 +5260,18 @@ test('#visStateReducer -> POLYGON: Toggle filter feature', t => { ); t.deepEqual( newReducer.datasets.puppy.filteredIndex, + [0, 1, 2, 3], + 'The dataset filteredIndex should not be affected by polygon filters' + ); + t.deepEqual( + newReducer.datasets.puppy.filteredIndexByLayer[newReducer.layers[0].id], [0, 2], - 'The polygon filter should be applied' + 'Should have per-layer polygon filtered index' + ); + t.equal( + newReducer.layerData[0].data.length, + 2, + 'Targeted layer should show polygon-filtered points' ); newReducer = reducer(newReducer, VisStateActions.toggleFilterFeature(0)); @@ -5264,6 +5284,120 @@ test('#visStateReducer -> POLYGON: Toggle filter feature', t => { [0, 1, 2, 3], "The polygon filter shouldn't be applied" ); + t.deepEqual( + newReducer.datasets.puppy.filteredIndexByLayer, + {}, + 'Per-layer polygon filtered index should be cleared when filter is disabled' + ); + t.equal( + newReducer.layerData[0].data.length, + 4, + 'Targeted layer should restore full data when polygon filter is disabled' + ); + + t.end(); +}); + +test('#visStateReducer -> APPLY_CPU_FILTER with polygon filter', t => { + const state = { + ...INITIAL_VIS_STATE + }; + + const datasets = [ + { + data: { + fields: [ + { + name: 'start_point_lat', + format: '', + fieldIdx: 0, + type: 'real', + analyzerType: 'FLOAT' + }, + { + name: 'start_point_lng', + format: '', + fieldIdx: 1, + type: 'real', + analyzerType: 'FLOAT' + }, + { + name: 'end_point_lat', + format: '', + fieldIdx: 2, + type: 'real', + analyzerType: 'FLOAT' + }, + { + name: 'end_point_lng', + format: '', + fieldIdx: 3, + type: 'real', + analyzerType: 'FLOAT' + } + ], + rows: mockPolygonData.data + }, + info: { + label: 'test.csv', + size: 144, + id: 'puppy' + } + } + ]; + + let newReducer = applyActions(reducer, state, [ + { + action: VisStateActions.updateVisData, + payload: [datasets, {centerMap: true, keepExistingConfig: false}, {}] + } + ]); + + newReducer = reducer(newReducer, VisStateActions.setFeatures([mockPolygonFeature])); + newReducer = reducer(newReducer, VisStateActions.setSelectedFeature(mockPolygonFeature)); + newReducer = reducer( + newReducer, + VisStateActions.setPolygonFilterLayer(newReducer.layers[0], mockPolygonFeature) + ); + + const layerId = newReducer.layers[0].id; + t.deepEqual( + newReducer.datasets.puppy.filteredIndex, + [0, 1, 2, 3], + 'dataset filteredIndex should ignore polygon filters' + ); + t.deepEqual( + newReducer.datasets.puppy.filteredIndexByLayer[layerId], + [0, 2], + 'per-layer index should keep rows inside the polygon' + ); + + newReducer = reducer(newReducer, VisStateActions.applyCPUFilter('puppy')); + + t.deepEqual( + newReducer.datasets.puppy.filteredIdxCPU, + [0, 2], + 'filtered export should apply polygon filters via per-layer indices' + ); + + // Targeting a second layer whose positions are outside the polygon: + // export should be the union of both layers' visible rows. + newReducer = reducer( + newReducer, + VisStateActions.setPolygonFilterLayer(newReducer.layers[1], mockPolygonFeature) + ); + newReducer = reducer(newReducer, VisStateActions.applyCPUFilter('puppy')); + + t.deepEqual( + newReducer.datasets.puppy.filteredIndexByLayer[newReducer.layers[1].id], + [], + 'second layer should have no rows inside the polygon' + ); + t.deepEqual( + newReducer.datasets.puppy.filteredIdxCPU, + [0, 2], + 'filtered export should keep the union of polygon-visible rows across targeted layers' + ); t.end(); }); @@ -5412,9 +5546,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), @@ -5424,6 +5558,208 @@ test('#visStateReducer -> POLYGON: setPolygonFilterLayer: H3', t => { t.end(); }); +function loadDualLonLatPolygonState() { + const datasets = [ + { + data: { + fields: [ + { + name: 'start_point_lat', + format: '', + fieldIdx: 0, + type: 'real', + analyzerType: 'FLOAT' + }, + { + name: 'start_point_lng', + format: '', + fieldIdx: 1, + type: 'real', + analyzerType: 'FLOAT' + }, + { + name: 'end_point_lat', + format: '', + fieldIdx: 2, + type: 'real', + analyzerType: 'FLOAT' + }, + { + name: 'end_point_lng', + format: '', + fieldIdx: 3, + type: 'real', + analyzerType: 'FLOAT' + } + ], + rows: mockDualLonLatRows + }, + info: { + label: 'trips.csv', + size: 144, + id: 'trips' + } + } + ]; + + return applyActions(reducer, {...INITIAL_VIS_STATE}, [ + { + action: VisStateActions.updateVisData, + payload: [datasets, {centerMap: true, keepExistingConfig: false}, {}] + } + ]); +} + +function pointLayerByLat(state, latField) { + return state.layers.find( + l => l.type === 'point' && l.config.columns?.lat?.value === latField + ); +} + +function layerDataIndexes(state, layer) { + return state.layerData[state.layers.indexOf(layer)].data.map(d => d.index); +} + +test('#visStateReducer -> POLYGON: independent polygons on two layers', t => { + let state = loadDualLonLatPolygonState(); + const startLayer = pointLayerByLat(state, 'start_point_lat'); + const endLayer = pointLayerByLat(state, 'end_point_lat'); + + state = reducer( + state, + VisStateActions.setFeatures([mockStartRegionPolygon, mockEndRegionPolygon]) + ); + state = reducer(state, VisStateActions.setSelectedFeature(mockStartRegionPolygon)); + state = reducer( + state, + VisStateActions.setPolygonFilterLayer(startLayer, mockStartRegionPolygon) + ); + state = reducer(state, VisStateActions.setSelectedFeature(mockEndRegionPolygon)); + state = reducer(state, VisStateActions.setPolygonFilterLayer(endLayer, mockEndRegionPolygon)); + + t.equal(state.filters.length, 2, 'Should create one polygon filter per feature'); + t.deepEqual( + state.datasets.trips.filteredIndex, + [0, 1, 2, 3], + 'dataset filteredIndex should ignore polygon filters' + ); + t.deepEqual( + state.datasets.trips.filteredIndexByLayer[startLayer.id], + [0, 1], + 'start layer should keep only points inside the start-region polygon' + ); + t.deepEqual( + state.datasets.trips.filteredIndexByLayer[endLayer.id], + [1, 2], + 'end layer should keep only points inside the end-region polygon' + ); + t.deepEqual( + layerDataIndexes(state, startLayer), + [0, 1], + 'start layer data should match its per-layer polygon index' + ); + t.deepEqual( + layerDataIndexes(state, endLayer), + [1, 2], + 'end layer data should match its per-layer polygon index' + ); + + state = reducer(state, VisStateActions.applyCPUFilter('trips')); + t.deepEqual( + state.datasets.trips.filteredIdxCPU, + [0, 1, 2], + 'filtered export should be the union of independently visible rows' + ); + + t.end(); +}); + +test('#visStateReducer -> POLYGON: intersecting polygons AND on the same layer', t => { + let state = loadDualLonLatPolygonState(); + const startLayer = pointLayerByLat(state, 'start_point_lat'); + const endLayer = pointLayerByLat(state, 'end_point_lat'); + + state = reducer( + state, + VisStateActions.setFeatures([mockStartRegionPolygon, mockEastOverlapPolygon]) + ); + state = reducer(state, VisStateActions.setSelectedFeature(mockStartRegionPolygon)); + state = reducer( + state, + VisStateActions.setPolygonFilterLayer(startLayer, mockStartRegionPolygon) + ); + state = reducer(state, VisStateActions.setSelectedFeature(mockEastOverlapPolygon)); + state = reducer( + state, + VisStateActions.setPolygonFilterLayer(startLayer, mockEastOverlapPolygon) + ); + + t.equal(state.filters.length, 2, 'Should create two polygon filters on the same layer'); + t.deepEqual( + state.filters.map(f => f.layerId), + [[startLayer.id], [startLayer.id]], + 'Both polygons should target only the start layer' + ); + t.deepEqual( + state.datasets.trips.filteredIndexByLayer[startLayer.id], + [1], + 'start layer should keep only the intersection of both polygons' + ); + t.deepEqual( + layerDataIndexes(state, startLayer), + [1], + 'start layer data should show only the intersecting point' + ); + t.equal( + state.datasets.trips.filteredIndexByLayer[endLayer.id], + undefined, + 'untargeted end layer should not have a per-layer polygon index' + ); + t.deepEqual( + layerDataIndexes(state, endLayer), + [0, 1, 2, 3], + 'untargeted end layer should keep all points' + ); + + state = reducer(state, VisStateActions.toggleFilterFeature(1)); + t.deepEqual( + state.datasets.trips.filteredIndexByLayer[startLayer.id], + [0, 1], + 'disabling one polygon should restore the remaining polygon filter' + ); + t.deepEqual( + layerDataIndexes(state, startLayer), + [0, 1], + 'start layer data should match the remaining polygon' + ); + + t.end(); +}); + +test('#visStateReducer -> POLYGON: disjoint polygons AND on the same layer', t => { + let state = loadDualLonLatPolygonState(); + const startLayer = pointLayerByLat(state, 'start_point_lat'); + + state = reducer(state, VisStateActions.setFeatures([mockWestOnlyPolygon, mockEastOnlyPolygon])); + state = reducer(state, VisStateActions.setSelectedFeature(mockWestOnlyPolygon)); + state = reducer(state, VisStateActions.setPolygonFilterLayer(startLayer, mockWestOnlyPolygon)); + state = reducer(state, VisStateActions.setSelectedFeature(mockEastOnlyPolygon)); + state = reducer(state, VisStateActions.setPolygonFilterLayer(startLayer, mockEastOnlyPolygon)); + + t.deepEqual( + state.datasets.trips.filteredIndexByLayer[startLayer.id], + [], + 'start layer should keep no points when two polygons do not overlap' + ); + t.deepEqual( + layerDataIndexes(state, startLayer), + [], + 'start layer data should be empty when polygon filters have no intersection' + ); + + t.end(); +}); + test('#uiStateReducer -> SET_EDITOR_MODE', t => { const newState = reducer(INITIAL_VIS_STATE, VisStateActions.setEditorMode(EDITOR_MODES.EDIT)); diff --git a/test/node/utils/plot-test.js b/test/node/utils/plot-test.js index 68d4d6bf2f..526145dd37 100644 --- a/test/node/utils/plot-test.js +++ b/test/node/utils/plot-test.js @@ -2,10 +2,73 @@ // Copyright contributors to the kepler.gl project import test from 'tape'; -import {histogramFromThreshold, histogramFromValues} from '@kepler.gl/utils'; +import { + histogramFromThreshold, + histogramFromValues, + mergePolygonLayerIndexes, + runGpuFilterForPlot +} from '@kepler.gl/utils'; const values1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; +test('Utils -> mergePolygonLayerIndexes', t => { + const baseIndex = [0, 1, 2, 3]; + + t.deepEqual( + mergePolygonLayerIndexes(baseIndex, {}), + baseIndex, + 'should return base index when no layers are polygon-filtered' + ); + + t.deepEqual( + mergePolygonLayerIndexes(baseIndex, {layerA: [0, 2]}), + [0, 2], + 'should keep rows visible on a single targeted layer' + ); + + t.deepEqual( + mergePolygonLayerIndexes(baseIndex, {layerA: [0, 2], layerB: [1]}), + [0, 1, 2], + 'should keep the union of rows visible on any targeted layer' + ); + + t.deepEqual( + mergePolygonLayerIndexes(baseIndex, {layerA: [], layerB: []}), + [], + 'should export no rows when all targeted layers are empty' + ); + + t.end(); +}); + +test('Utils -> runGpuFilterForPlot applies polygon layer indexes', t => { + const dataset = { + id: 'puppy', + filteredIndex: [0, 1, 2, 3], + filteredIndexByLayer: {layerA: [0, 2]}, + dataContainer: {}, + gpuFilter: { + filterRange: [], + filterValueUpdateTriggers: {}, + filterValueAccessor: () => () => () => [] + } + }; + + t.deepEqual( + runGpuFilterForPlot(dataset), + [0, 2], + 'should start plots from polygon-visible rows when filteredIndexByLayer is set' + ); + + t.deepEqual( + runGpuFilterForPlot({...dataset, filteredIndexByLayer: {}}), + [0, 1, 2, 3], + 'should fall back to filteredIndex when no polygon layer indexes exist' + ); + + t.end(); +}); + test('Utils -> histogramFromThreshold', t => { const thresholds1 = [1, 3, 6, 13];