From 00c7bf73da9c38bed984b18b2a5d5901a1223f28 Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Thu, 14 Nov 2024 15:12:24 -0500 Subject: [PATCH] feat(picking): Add Picking Model API (#31) --- src/models/model.ts | 19 +++++-- src/widget-sources/types.ts | 51 +++++++++++++++++++ src/widget-sources/widget-base-source.ts | 44 +++++++++++++++- .../widget-sources/widget-base-source.test.ts | 49 ++++++++++++++++++ 4 files changed, 157 insertions(+), 6 deletions(-) diff --git a/src/models/model.ts b/src/models/model.ts index 08c2edd..cfd6057 100644 --- a/src/models/model.ts +++ b/src/models/model.ts @@ -16,6 +16,7 @@ const AVAILABLE_MODELS = [ 'category', 'histogram', 'formula', + 'pick', 'timeseries', 'range', 'scatterplot', @@ -74,7 +75,13 @@ export function executeModel(props: { let url = `${apiBaseUrl}/v3/sql/${connectionName}/model/${model}`; - const {filters, filtersLogicalOperator = 'and', data} = source; + const { + data, + filters, + filtersLogicalOperator = 'and', + geoColumn = DEFAULT_GEO_COLUMN, + } = source; + const queryParameters = source.queryParameters ? JSON.stringify(source.queryParameters) : ''; @@ -89,12 +96,14 @@ export function executeModel(props: { filtersLogicalOperator, }; + // Picking Model API requires 'spatialDataColumn'. + if (model === 'pick') { + queryParams.spatialDataColumn = geoColumn; + } + // API supports multiple filters, we apply it only to geoColumn const spatialFilters = source.spatialFilter - ? { - [source.geoColumn ? source.geoColumn : DEFAULT_GEO_COLUMN]: - source.spatialFilter, - } + ? {[geoColumn]: source.spatialFilter} : undefined; if (spatialFilters) { diff --git a/src/widget-sources/types.ts b/src/widget-sources/types.ts index 05b035c..12f2e9e 100644 --- a/src/widget-sources/types.ts +++ b/src/widget-sources/types.ts @@ -1,3 +1,4 @@ +import {TileResolution} from '../sources/types'; import { GroupDateType, SortColumnType, @@ -23,6 +24,49 @@ export interface CategoryRequestOptions extends BaseRequestOptions { operationColumn?: string; } +/** + * Options for {@link WidgetBaseSource#getFeatures}. + * @experimental + * @internal + */ +export interface FeaturesRequestOptions extends BaseRequestOptions { + /** + * Feature IDs, as found in `_carto_feature_id`. Feature IDs are a hash + * of geometry, and features with identical geometry will have the same + * feature ID. Order is important; features in the result set will be + * sorted according to the order of IDs in the request. + */ + featureIds: string[]; + + /** + * Columns to be returned for each picked object. Note that for datasets + * containing features with identical geometry, more than one result per + * requested feature ID may be returned. To match results back to the + * requested feature ID, include `_carto_feature_id` in the columns list. + */ + columns: string[]; + + /** Topology of objects to be picked. */ + dataType: 'points' | 'lines' | 'polygons'; + + /** Zoom level, required if using 'points' data type. */ + z?: number; + + /** + * Maximum number of objects to return in the result set. For datasets + * containing features with identical geometry, those features will have + * the same feature IDs, and so more results may be returned than feature IDs + * given in the request. + */ + limit?: number; + + /** + * Must match `tileResolution` used when obtaining the `_carto_feature_id` + * column, typically in a layer's tile requests. + */ + tileResolution?: TileResolution; +} + /** Options for {@link WidgetBaseSource#getFormula}. */ export interface FormulaRequestOptions extends BaseRequestOptions { column: string; @@ -77,6 +121,13 @@ export interface TimeSeriesRequestOptions extends BaseRequestOptions { * WIDGET API RESPONSES */ +/** + * Response from {@link WidgetBaseSource#getFeatures}. + * @experimental + * @internal + */ +export type FeaturesResponse = {rows: Record[]}; + /** Response from {@link WidgetBaseSource#getFormula}. */ export type FormulaResponse = {value: number}; diff --git a/src/widget-sources/widget-base-source.ts b/src/widget-sources/widget-base-source.ts index 3dde07d..3768aba 100644 --- a/src/widget-sources/widget-base-source.ts +++ b/src/widget-sources/widget-base-source.ts @@ -2,6 +2,8 @@ import {executeModel} from '../models/index.js'; import { CategoryRequestOptions, CategoryResponse, + FeaturesRequestOptions, + FeaturesResponse, FormulaRequestOptions, FormulaResponse, HistogramRequestOptions, @@ -21,7 +23,10 @@ import {getClient} from '../client.js'; import {ModelSource} from '../models/model.js'; import {SourceOptions} from '../sources/index.js'; import {ApiVersion, DEFAULT_API_BASE_URL} from '../constants.js'; -import {DEFAULT_GEO_COLUMN} from '../constants-internal.js'; +import { + DEFAULT_GEO_COLUMN, + DEFAULT_TILE_RESOLUTION, +} from '../constants-internal.js'; export interface WidgetBaseSourceProps extends Omit { apiVersion?: ApiVersion; @@ -105,6 +110,43 @@ export abstract class WidgetBaseSource { }).then((res: CategoriesModelResponse) => normalizeObjectKeys(res.rows)); } + /**************************************************************************** + * FEATURES + */ + + /** + * Given a list of feature IDs (as found in `_carto_feature_id`) returns all + * matching features. In datasets containing features with duplicate geometries, + * feature IDs may be duplicated (IDs are a hash of geometry) and so more + * results may be returned than IDs in the request. + * @internal + * @experimental + */ + async getFeatures( + options: FeaturesRequestOptions + ): Promise { + const {filterOwner, spatialFilter, abortController, ...params} = options; + const {columns, dataType, featureIds, z, limit, tileResolution} = params; + + type FeaturesModelResponse = {rows: Record[]}; + + return executeModel({ + model: 'pick', + source: {...this.getModelSource(filterOwner), spatialFilter}, + params: { + columns, + dataType, + featureIds, + z, + limit: limit || 1000, + tileResolution: tileResolution || DEFAULT_TILE_RESOLUTION, + }, + opts: {abortController}, + }).then((res: FeaturesModelResponse) => ({ + rows: normalizeObjectKeys(res.rows), + })); + } + /**************************************************************************** * FORMULA */ diff --git a/test/widget-sources/widget-base-source.test.ts b/test/widget-sources/widget-base-source.test.ts index 92ddfe8..0f3350b 100644 --- a/test/widget-sources/widget-base-source.test.ts +++ b/test/widget-sources/widget-base-source.test.ts @@ -224,6 +224,55 @@ test('filters - owner', async () => { } }); +/****************************************************************************** + * getFeatures + */ + +test('getFeatures', async () => { + const widgetSource = new WidgetTestSource({ + accessToken: '', + connectionName: 'carto_dw', + }); + + const expectedRows = [ + {_carto_feature_id: 'a', name: 'Veggie Mart', revenue: 1200}, + {_carto_feature_id: 'b', name: 'EZ Drive Thru', revenue: 400}, + {_carto_feature_id: 'c', name: "Buddy's Convenience", revenue: 800}, + ]; + + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + createMockResponse({rows: expectedRows, meta: {foo: 'bar'}}) + ); + vi.stubGlobal('fetch', mockFetch); + + const actualFeatures = await widgetSource.getFeatures({ + columns: ['_carto_feature_id', 'name', 'revenue'], + featureIds: ['a', 'b', 'c'], + dataType: 'points', + }); + + expect(mockFetch).toHaveBeenCalledOnce(); + expect(actualFeatures).toEqual({rows: expectedRows}); + + const params = new URL(mockFetch.mock.lastCall[0]).searchParams.entries(); + expect(Object.fromEntries(params)).toMatchObject({ + type: 'test', + source: 'test-data', + params: JSON.stringify({ + columns: ['_carto_feature_id', 'name', 'revenue'], + dataType: 'points', + featureIds: ['a', 'b', 'c'], + limit: 1000, + tileResolution: 0.5, + }), + queryParameters: '', + filters: JSON.stringify({}), + filtersLogicalOperator: 'and', + }); +}); + /****************************************************************************** * getFormula */