diff --git a/.gitignore b/.gitignore index c49f67b..706eac5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ custom-elements.json !.yarn/releases !.yarn/sdks !.yarn/versions +.env diff --git a/examples/05-spatial-index/app.tsx b/examples/05-spatial-index/app.tsx new file mode 100644 index 0000000..55731ff --- /dev/null +++ b/examples/05-spatial-index/app.tsx @@ -0,0 +1,128 @@ +import React, {useEffect, useMemo, useState} from 'react'; +import {Map} from 'react-map-gl/maplibre'; +import DeckGL from '@deck.gl/react'; +import {h3TableSource, Filters} from '@carto/api-client'; +import { + CategoryWidget, + FormulaWidget, + HistogramWidget, + PieWidget, + ScatterWidget, + TableWidget, +} from '../components/index-react.js'; +import {MapView} from '@deck.gl/core'; +import {H3TileLayer} from '@deck.gl/carto'; +import {FilterEvent} from '../components/types.js'; + +const MAP_VIEW = new MapView({repeat: true}); +const MAP_STYLE = + 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json'; +const INITIAL_VIEW_STATE = { + latitude: 37.3753636, + longitude: -5.9962577, + zoom: 6, +}; + +export function App(): JSX.Element { + const [viewState, setViewState] = useState({...INITIAL_VIEW_STATE}); + const [filters, setFilters] = useState({}); + const [attributionHTML, setAttributionHTML] = useState(''); + + // Update sources. + const data = useMemo(() => { + return h3TableSource({ + accessToken: import.meta.env.VITE_CARTO_ACCESS_TOKEN, + connectionName: 'carto_dw', + tableName: + 'carto-demo-data.demo_tables.derived_spatialfeatures_esp_h3res8_v1_yearly_v2', + filters, + aggregationExp: 'sum(population) as population', + }); + }, [filters]); + + // Update layers. + const layers = useMemo(() => { + return [ + new H3TileLayer({ + id: 'retail_stores', + data, + pointRadiusMinPixels: 4, + getFillColor: [200, 0, 80], + }), + ]; + }, [data]); + + useEffect(() => { + data?.then(({attribution}) => setAttributionHTML(attribution)); + }, [data]); + + return ( + <> +
+

Spatial Index

+ ← Back +
+
+ setViewState(viewState)} + > + + +
+
+ + + setFilters((e as FilterEvent).detail.filters)} + > + setFilters((e as FilterEvent).detail.filters)} + > + + + +
+ + + ); +} diff --git a/examples/05-spatial-index/index.html b/examples/05-spatial-index/index.html new file mode 100644 index 0000000..7f08a0e --- /dev/null +++ b/examples/05-spatial-index/index.html @@ -0,0 +1,12 @@ + + + + + Examples / Spatial Index + + + +
+ + + diff --git a/examples/05-spatial-index/react.tsx b/examples/05-spatial-index/react.tsx new file mode 100644 index 0000000..bce03ce --- /dev/null +++ b/examples/05-spatial-index/react.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {App} from './app'; + +const container = document.querySelector('#app')!; +createRoot(container).render(); diff --git a/examples/components/widgets/category-widget.ts b/examples/components/widgets/category-widget.ts index 089a4bd..8376fe3 100644 --- a/examples/components/widgets/category-widget.ts +++ b/examples/components/widgets/category-widget.ts @@ -57,6 +57,7 @@ export class CategoryWidget extends BaseWidget { spatialFilter: this.getSpatialFilterOrViewState(), operation, column, + spatialIndexReferenceViewState: this.viewState ?? undefined, }); }, args: () => diff --git a/examples/components/widgets/formula-widget.ts b/examples/components/widgets/formula-widget.ts index a55af3e..7fcb1be 100644 --- a/examples/components/widgets/formula-widget.ts +++ b/examples/components/widgets/formula-widget.ts @@ -59,6 +59,7 @@ export class FormulaWidget extends BaseWidget { operation, column, spatialFilter: this.getSpatialFilterOrViewState(), + spatialIndexReferenceViewState: this.viewState ?? undefined, }); return response.value; }, diff --git a/examples/components/widgets/histogram-widget.ts b/examples/components/widgets/histogram-widget.ts index dc7af4b..097d346 100644 --- a/examples/components/widgets/histogram-widget.ts +++ b/examples/components/widgets/histogram-widget.ts @@ -58,6 +58,7 @@ export class HistogramWidget extends BaseWidget { column, operation, ticks, + spatialIndexReferenceViewState: this.viewState ?? undefined, }); }, args: () => diff --git a/examples/components/widgets/scatter-widget.ts b/examples/components/widgets/scatter-widget.ts index 674b04d..a3f7e80 100644 --- a/examples/components/widgets/scatter-widget.ts +++ b/examples/components/widgets/scatter-widget.ts @@ -65,6 +65,7 @@ export class ScatterWidget extends BaseWidget { xAxisJoinOperation, yAxisColumn, yAxisJoinOperation, + spatialIndexReferenceViewState: this.viewState ?? undefined, }); }, args: () => diff --git a/examples/components/widgets/table-widget.ts b/examples/components/widgets/table-widget.ts index f400101..c70bb4b 100644 --- a/examples/components/widgets/table-widget.ts +++ b/examples/components/widgets/table-widget.ts @@ -69,6 +69,7 @@ export class TableWidget extends BaseWidget { ...(sortBy && {sortBy, sortDirection}), limit, spatialFilter: this.getSpatialFilterOrViewState(), + spatialIndexReferenceViewState: this.viewState ?? undefined, }); }, args: () => @@ -136,11 +137,14 @@ function renderTableRow(row: unknown[]) { `; } -const _numberFormatter = new Intl.NumberFormat(); +const _numberFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, + notation: 'compact', +}); function renderTableCell(value: unknown) { let formattedValue: string; if (typeof value === 'number') { - return _numberFormatter.format(value); + formattedValue = _numberFormatter.format(value); } else { formattedValue = String(value); } diff --git a/examples/index.html b/examples/index.html index dba85e6..296c17a 100644 --- a/examples/index.html +++ b/examples/index.html @@ -19,7 +19,12 @@

Frameworks

  • react
  • svelte
  • vue
  • -
  • angular (TODO)
  • + +

    Features

    +
      +
    1. + spatial index +
    diff --git a/src/api/query.ts b/src/api/query.ts index b21b1b8..8ba639c 100644 --- a/src/api/query.ts +++ b/src/api/query.ts @@ -12,8 +12,7 @@ import {buildQueryUrl} from './endpoints'; import {requestWithParameters} from './request-with-parameters'; import {APIErrorContext} from './carto-api-error'; -export type QueryOptions = SourceOptions & - Omit; +export type QueryOptions = SourceOptions & QuerySourceOptions; type UrlParameters = {q: string; queryParameters?: string}; export const query = async function ( diff --git a/src/models/model.ts b/src/models/model.ts index cfd6057..c890775 100644 --- a/src/models/model.ts +++ b/src/models/model.ts @@ -7,9 +7,10 @@ import { SpatialFilter, } from '../types.js'; import {$TODO} from '../types-internal.js'; -import {assert} from '../utils.js'; +import {assert, isPureObject} from '../utils.js'; import {ModelRequestOptions, makeCall} from './common.js'; import {ApiVersion} from '../constants.js'; +import {SpatialDataType, SpatialFilterPolyfillMode} from '../sources/types.js'; /** @internalRemarks Source: @carto/react-api */ const AVAILABLE_MODELS = [ @@ -35,9 +36,14 @@ export interface ModelSource { data: string; filters?: Record; filtersLogicalOperator?: FilterLogicalOperator; - geoColumn?: string; spatialFilter?: SpatialFilter; queryParameters?: QueryParameters; + spatialDataColumn?: string; + spatialDataType?: SpatialDataType; + spatialFiltersResolution?: number; + spatialFiltersMode?: SpatialFilterPolyfillMode; + /** original resolution of the spatial index data as stored in the DW */ + dataResolution?: number; } const {V3} = ApiVersion; @@ -79,50 +85,51 @@ export function executeModel(props: { data, filters, filtersLogicalOperator = 'and', - geoColumn = DEFAULT_GEO_COLUMN, + spatialDataType = 'geo', + spatialFiltersMode = 'intersects', + spatialFiltersResolution = 0, } = source; - const queryParameters = source.queryParameters - ? JSON.stringify(source.queryParameters) - : ''; - - const queryParams: Record = { + const queryParams: Record = { type, client: clientId, source: data, - params: JSON.stringify(params), - queryParameters, - filters: JSON.stringify(filters), + params, + queryParameters: source.queryParameters || '', + filters, filtersLogicalOperator, }; + const spatialDataColumn = source.spatialDataColumn || DEFAULT_GEO_COLUMN; + // Picking Model API requires 'spatialDataColumn'. if (model === 'pick') { - queryParams.spatialDataColumn = geoColumn; + queryParams.spatialDataColumn = spatialDataColumn; } - // API supports multiple filters, we apply it only to geoColumn + // API supports multiple filters, we apply it only to spatialDataColumn const spatialFilters = source.spatialFilter - ? {[geoColumn]: source.spatialFilter} + ? {[spatialDataColumn]: source.spatialFilter} : undefined; if (spatialFilters) { - queryParams.spatialFilters = JSON.stringify(spatialFilters); + queryParams.spatialFilters = spatialFilters; + queryParams.spatialDataColumn = spatialDataColumn; + queryParams.spatialDataType = spatialDataType; + } + + if (spatialDataType !== 'geo') { + if (spatialFiltersResolution > 0) { + queryParams.spatialFiltersResolution = spatialFiltersResolution; + } + queryParams.spatialFiltersMode = spatialFiltersMode; } const urlWithSearchParams = - url + '?' + new URLSearchParams(queryParams).toString(); + url + '?' + objectToURLSearchParams(queryParams).toString(); const isGet = urlWithSearchParams.length <= REQUEST_GET_MAX_URL_LENGTH; if (isGet) { url = urlWithSearchParams; - } else { - // undo the JSON.stringify, @TODO find a better pattern - queryParams.params = params as $TODO; - queryParams.filters = filters as $TODO; - queryParams.queryParameters = source.queryParameters as $TODO; - if (spatialFilters) { - queryParams.spatialFilters = spatialFilters as $TODO; - } } return makeCall({ url, @@ -134,3 +141,19 @@ export function executeModel(props: { }, }); } + +function objectToURLSearchParams(object: Record) { + const params = new URLSearchParams(); + for (const key in object) { + if (isPureObject(object[key])) { + params.append(key, JSON.stringify(object[key])); + } else if (Array.isArray(object[key])) { + params.append(key, JSON.stringify(object[key])); + } else if (object[key] === null) { + params.append(key, 'null'); + } else if (object[key] !== undefined) { + params.append(key, String(object[key])); + } + } + return params; +} diff --git a/src/sources/h3-query-source.ts b/src/sources/h3-query-source.ts index a08eef7..7240f29 100644 --- a/src/sources/h3-query-source.ts +++ b/src/sources/h3-query-source.ts @@ -19,6 +19,7 @@ export type H3QuerySourceOptions = SourceOptions & QuerySourceOptions & AggregationOptions & FilterOptions; + type UrlParameters = { aggregationExp: string; aggregationResLevel?: string; @@ -61,7 +62,12 @@ export const h3QuerySource = async function ( return baseSource('query', options, urlParameters).then( (result) => ({ ...(result as TilejsonResult), - widgetSource: new WidgetQuerySource(options), + widgetSource: new WidgetQuerySource({ + ...options, + // NOTE: passing redundant spatialDataColumn here to apply the default value 'h3' + spatialDataColumn, + spatialDataType: 'h3', + }), }) ); }; diff --git a/src/sources/h3-table-source.ts b/src/sources/h3-table-source.ts index 43b62d7..aed006c 100644 --- a/src/sources/h3-table-source.ts +++ b/src/sources/h3-table-source.ts @@ -57,7 +57,12 @@ export const h3TableSource = async function ( return baseSource('table', options, urlParameters).then( (result) => ({ ...(result as TilejsonResult), - widgetSource: new WidgetTableSource(options), + widgetSource: new WidgetTableSource({ + ...options, + // NOTE: passing redundant spatialDataColumn here to apply the default value 'h3' + spatialDataColumn, + spatialDataType: 'h3', + }), }) ); }; diff --git a/src/sources/quadbin-query-source.ts b/src/sources/quadbin-query-source.ts index 0f84bda..e72d210 100644 --- a/src/sources/quadbin-query-source.ts +++ b/src/sources/quadbin-query-source.ts @@ -63,7 +63,12 @@ export const quadbinQuerySource = async function ( return baseSource('query', options, urlParameters).then( (result) => ({ ...(result as TilejsonResult), - widgetSource: new WidgetQuerySource(options), + widgetSource: new WidgetQuerySource({ + ...options, + // NOTE: passing redundant spatialDataColumn here to apply the default value 'quadbin' + spatialDataColumn, + spatialDataType: 'quadbin', + }), }) ); }; diff --git a/src/sources/quadbin-table-source.ts b/src/sources/quadbin-table-source.ts index 8f4555c..05416e1 100644 --- a/src/sources/quadbin-table-source.ts +++ b/src/sources/quadbin-table-source.ts @@ -59,7 +59,12 @@ export const quadbinTableSource = async function ( return baseSource('table', options, urlParameters).then( (result) => ({ ...(result as TilejsonResult), - widgetSource: new WidgetTableSource(options), + widgetSource: new WidgetTableSource({ + ...options, + // NOTE: passing redundant spatialDataColumn here to apply the default value 'quadbin' + spatialDataColumn, + spatialDataType: 'quadbin', + }), }) ); }; diff --git a/src/sources/types.ts b/src/sources/types.ts index f3ea647..a96ddab 100644 --- a/src/sources/types.ts +++ b/src/sources/types.ts @@ -48,6 +48,33 @@ export type SourceOptionalOptions = { */ maxLengthURL?: number; + /** + * The column name and the type of geospatial support. + * + * If not present, defaults to `'geom'` for generic queries, `'quadbin'` for Quadbin sources and `'h3'` for H3 sources. + */ + spatialDataColumn?: string; + + /** + * The type of geospatial support. Defaults to `'geo'`. + */ + spatialDataType?: SpatialDataType; + + /** + * Relative resolution of a tile. Higher values increase density and data size. At `tileResolution = 1`, tile geometry is + * quantized to a 1024x1024 grid. Increasing or decreasing the resolution will increase or decrease the dimensions of + * the quantization grid proportionately. + * + * Supported `tileResolution` values, with corresponding grid sizes: + * + * - 0.25: 256x256 + * - 0.5: 512x512 + * - 1: 1024x1024 + * - 2: 2048x2048 + * - 4: 4096x4096 + */ + tileResolution?: TileResolution; + /** * By default, local in-memory caching is enabled. */ @@ -89,6 +116,11 @@ export type AggregationOptions = { * @default 6 for quadbin and 4 for h3 sources */ aggregationResLevel?: number; + + /** + * Original resolution of the spatial index data as stored in the DW + */ + dataResolution?: number; }; export type FilterOptions = { @@ -99,31 +131,9 @@ export type FilterOptions = { }; export type QuerySourceOptions = { - /** - * The column name and the type of geospatial support. - * - * If not present, defaults to `'geom'` for generic queries, `'quadbin'` for Quadbin sources and `'h3'` for H3 sources. - */ - spatialDataColumn?: string; - - /** SQL query. */ + /** Full SQL query with query paremeter placeholders (if any). */ sqlQuery: string; - /** - * Relative resolution of a tile. Higher values increase density and data size. At `tileResolution = 1`, tile geometry is - * quantized to a 1024x1024 grid. Increasing or decreasing the resolution will increase or decrease the dimensions of - * the quantization grid proportionately. - * - * Supported `tileResolution` values, with corresponding grid sizes: - * - * - 0.25: 256x256 - * - 0.5: 512x512 - * - 1: 1024x1024 - * - 2: 2048x2048 - * - 4: 4096x4096 - */ - tileResolution?: TileResolution; - /** * Values for named or positional paramteres in the query. * @@ -166,28 +176,6 @@ export type TableSourceOptions = { */ tableName: string; - /** - * The column name and the type of geospatial support. - * - * If not present, defaults to `'geom'` for generic tables, `'quadbin'` for Quadbin sources and `'h3'` for H3 sources. - */ - spatialDataColumn?: string; - - /** - * Relative resolution of a tile. Higher values increase density and data size. At `tileResolution = 1`, tile geometry is - * quantized to a 1024x1024 grid. Increasing or decreasing the resolution will increase or decrease the dimensions of - * the quantization grid proportionately. - * - * Supported `tileResolution` values, with corresponding grid sizes: - * - * - 0.25: 256x256 - * - 0.5: 512x512 - * - 1: 1024x1024 - * - 2: 2048x2048 - * - 4: 4096x4096 - */ - tileResolution?: TileResolution; - /** * Comma-separated aggregation expressions. If assigned on a vector source, source is grouped by geometry and then aggregated. * @@ -216,6 +204,14 @@ export type ColumnsOption = { export type SpatialDataType = 'geo' | 'h3' | 'quadbin'; +/** + * Strategy used for covering spatial filter geometry with spatial indexes. + * See https://docs.carto.com/data-and-analysis/analytics-toolbox-for-bigquery/sql-reference/quadbin#quadbin_polyfill_mode + * or https://docs.carto.com/data-and-analysis/analytics-toolbox-for-bigquery/sql-reference/h3#h3_polyfill_mode for more information. + * @internalRemarks Source: cloud-native maps-api + * */ +export type SpatialFilterPolyfillMode = 'center' | 'intersects' | 'contains'; + export type TilejsonMapInstantiation = MapInstantiation & { tilejson: {url: string[]}; }; diff --git a/src/sources/vector-query-source.ts b/src/sources/vector-query-source.ts index 6cd17ce..51971e3 100644 --- a/src/sources/vector-query-source.ts +++ b/src/sources/vector-query-source.ts @@ -72,7 +72,10 @@ export const vectorQuerySource = async function ( return baseSource('query', options, urlParameters).then( (result) => ({ ...(result as TilejsonResult), - widgetSource: new WidgetQuerySource(options), + widgetSource: new WidgetQuerySource({ + ...options, + spatialDataType: 'geo', + }), }) ); }; diff --git a/src/sources/vector-table-source.ts b/src/sources/vector-table-source.ts index 541cde1..c676f43 100644 --- a/src/sources/vector-table-source.ts +++ b/src/sources/vector-table-source.ts @@ -67,7 +67,10 @@ export const vectorTableSource = async function ( return baseSource('table', options, urlParameters).then( (result) => ({ ...(result as TilejsonResult), - widgetSource: new WidgetTableSource(options), + widgetSource: new WidgetTableSource({ + ...options, + spatialDataType: 'geo', + }), }) ); }; diff --git a/src/spatial-index.ts b/src/spatial-index.ts new file mode 100644 index 0000000..6ff33a3 --- /dev/null +++ b/src/spatial-index.ts @@ -0,0 +1,111 @@ +import { + DEFAULT_AGGREGATION_RES_LEVEL_H3, + DEFAULT_AGGREGATION_RES_LEVEL_QUADBIN, +} from './constants-internal'; +import type {ModelSource} from './models/model'; +import type {AggregationOptions} from './sources/types'; +import {assert} from './utils'; +import type {ViewState} from './widget-sources'; + +const DEFAULT_TILE_SIZE = 512; +const QUADBIN_ZOOM_MAX_OFFSET = 4; + +export function getSpatialFiltersResolution( + source: Partial, + viewState: ViewState +): number | undefined { + const dataResolution = source.dataResolution ?? Number.MAX_VALUE; + + const aggregationResLevel = + source.aggregationResLevel ?? + (source.spatialDataType === 'h3' + ? DEFAULT_AGGREGATION_RES_LEVEL_H3 + : DEFAULT_AGGREGATION_RES_LEVEL_QUADBIN); + + const aggregationResLevelOffset = Math.max( + 0, + Math.floor(aggregationResLevel) + ); + + const currentZoomInt = Math.ceil(viewState.zoom); + if (source.spatialDataType === 'h3') { + const tileSize = DEFAULT_TILE_SIZE; + const maxResolutionForZoom = + maxH3SpatialFiltersResolutions.find( + ([zoom]) => zoom === currentZoomInt + )?.[1] ?? Math.max(0, currentZoomInt - 3); + + const maxSpatialFiltersResolution = maxResolutionForZoom + ? Math.min(dataResolution, maxResolutionForZoom) + : dataResolution; + + const hexagonResolution = + getHexagonResolution(viewState, tileSize) + aggregationResLevelOffset; + + return Math.min(hexagonResolution, maxSpatialFiltersResolution); + } + + if (source.spatialDataType === 'quadbin') { + const maxResolutionForZoom = currentZoomInt + QUADBIN_ZOOM_MAX_OFFSET; + const maxSpatialFiltersResolution = Math.min( + dataResolution, + maxResolutionForZoom + ); + + const quadsResolution = + Math.floor(viewState.zoom) + aggregationResLevelOffset; + return Math.min(quadsResolution, maxSpatialFiltersResolution); + } + + return undefined; +} + +const maxH3SpatialFiltersResolutions = [ + [20, 14], + [19, 13], + [18, 12], + [17, 11], + [16, 10], + [15, 9], + [14, 8], + [13, 7], + [12, 7], + [11, 7], + [10, 6], + [9, 6], + [8, 5], + [7, 4], + [6, 4], + [5, 3], + [4, 2], + [3, 1], + [2, 1], + [1, 0], +]; + +// stolen from https://github.com/visgl/deck.gl/blob/master/modules/carto/src/layers/h3-tileset-2d.ts + +// Relative scale factor (0 = no biasing, 2 = a few hexagons cover view) +const BIAS = 2; + +// Resolution conversion function. Takes a WebMercatorViewport and returns +// a H3 resolution such that the screen space size of the hexagons is +// similar +export function getHexagonResolution( + viewport: {zoom: number; latitude: number}, + tileSize: number +): number { + // Difference in given tile size compared to deck's internal 512px tile size, + // expressed as an offset to the viewport zoom. + const zoomOffset = Math.log2(tileSize / DEFAULT_TILE_SIZE); + const hexagonScaleFactor = (2 / 3) * (viewport.zoom - zoomOffset); + const latitudeScaleFactor = Math.log( + 1 / Math.cos((Math.PI * viewport.latitude) / 180) + ); + + // Clip and bias + return Math.max( + 0, + Math.floor(hexagonScaleFactor + latitudeScaleFactor - BIAS) + ); +} diff --git a/src/utils.ts b/src/utils.ts index e4048fc..7f6f927 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -57,7 +57,7 @@ export function normalizeObjectKeys>(el: R): R { } /** @internalRemarks Source: @carto/react-core */ -export function assert(condition: unknown, message: string) { +export function assert(condition: unknown, message: string): asserts condition { if (!condition) { throw new Error(message); } diff --git a/src/widget-sources/types.ts b/src/widget-sources/types.ts index 12f2e9e..9d96868 100644 --- a/src/widget-sources/types.ts +++ b/src/widget-sources/types.ts @@ -1,4 +1,4 @@ -import {TileResolution} from '../sources/types'; +import {SpatialFilterPolyfillMode, TileResolution} from '../sources/types'; import { GroupDateType, SortColumnType, @@ -10,9 +10,18 @@ import { * WIDGET API REQUESTS */ +export interface ViewState { + zoom: number; + latitude: number; + longitude: number; +} + /** Common options for {@link WidgetBaseSource} requests. */ interface BaseRequestOptions { spatialFilter?: SpatialFilter; + spatialFiltersMode?: SpatialFilterPolyfillMode; + /** Required for table- and query-based spatial index sources (H3, Quadbin). */ + spatialIndexReferenceViewState?: ViewState; abortController?: AbortController; filterOwner?: string; } diff --git a/src/widget-sources/widget-base-source.ts b/src/widget-sources/widget-base-source.ts index 7e7e4f3..a9c85f6 100644 --- a/src/widget-sources/widget-base-source.ts +++ b/src/widget-sources/widget-base-source.ts @@ -16,21 +16,20 @@ import { TableResponse, TimeSeriesRequestOptions, TimeSeriesResponse, + ViewState, } from './types.js'; -import {FilterLogicalOperator, Filter} from '../types.js'; +import {FilterLogicalOperator, Filter, SpatialFilter} from '../types.js'; import {getApplicableFilters, normalizeObjectKeys} from '../utils.js'; 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, - DEFAULT_TILE_RESOLUTION, -} from '../constants-internal.js'; +import {DEFAULT_TILE_RESOLUTION} from '../constants-internal.js'; +import {getSpatialFiltersResolution} from '../spatial-index.js'; +import {AggregationOptions} from '../sources/types.js'; export interface WidgetBaseSourceProps extends Omit { apiVersion?: ApiVersion; - geoColumn?: string; filters?: Record; filtersLogicalOperator?: FilterLogicalOperator; } @@ -51,7 +50,6 @@ export abstract class WidgetBaseSource { clientId: getClient(), filters: {}, filtersLogicalOperator: 'and', - geoColumn: DEFAULT_GEO_COLUMN, }; constructor(props: Props) { @@ -78,10 +76,31 @@ export abstract class WidgetBaseSource { connectionName: props.connectionName, filters: getApplicableFilters(owner, props.filters), filtersLogicalOperator: props.filtersLogicalOperator, - geoColumn: props.geoColumn, + spatialDataType: props.spatialDataType, + spatialDataColumn: props.spatialDataColumn, + dataResolution: (props as Partial).dataResolution, }; } + protected _getSpatialFiltersResolution( + source: Omit, + spatialFilter?: SpatialFilter, + referenceViewState?: ViewState + ): number | undefined { + // spatialFiltersResolution applies only to spatial index sources. + if (!spatialFilter || source.spatialDataType === 'geo') { + return; + } + + if (!referenceViewState) { + throw new Error( + 'Missing required option, "spatialIndexReferenceViewState".' + ); + } + + return getSpatialFiltersResolution(source, referenceViewState); + } + /**************************************************************************** * CATEGORIES */ @@ -93,14 +112,32 @@ export abstract class WidgetBaseSource { async getCategories( options: CategoryRequestOptions ): Promise { - const {filterOwner, spatialFilter, abortController, ...params} = options; + const { + filterOwner, + spatialFilter, + spatialFiltersMode, + spatialIndexReferenceViewState, + abortController, + ...params + } = options; const {column, operation, operationColumn} = params; + const source = this.getModelSource(filterOwner); + const spatialFiltersResolution = this._getSpatialFiltersResolution( + source, + spatialFilter, + spatialIndexReferenceViewState + ); type CategoriesModelResponse = {rows: {name: string; value: number}[]}; return executeModel({ model: 'category', - source: {...this.getModelSource(filterOwner), spatialFilter}, + source: { + ...source, + spatialFiltersResolution, + spatialFiltersMode, + spatialFilter, + }, params: { column, operation, @@ -125,14 +162,32 @@ export abstract class WidgetBaseSource { async getFeatures( options: FeaturesRequestOptions ): Promise { - const {filterOwner, spatialFilter, abortController, ...params} = options; + const { + filterOwner, + spatialFilter, + spatialFiltersMode, + spatialIndexReferenceViewState, + abortController, + ...params + } = options; const {columns, dataType, featureIds, z, limit, tileResolution} = params; + const source = this.getModelSource(filterOwner); + const spatialFiltersResolution = this._getSpatialFiltersResolution( + source, + spatialFilter, + spatialIndexReferenceViewState + ); type FeaturesModelResponse = {rows: Record[]}; return executeModel({ model: 'pick', - source: {...this.getModelSource(filterOwner), spatialFilter}, + source: { + ...source, + spatialFiltersResolution, + spatialFiltersMode, + spatialFilter, + }, params: { columns, dataType, @@ -158,17 +213,30 @@ export abstract class WidgetBaseSource { const { filterOwner, spatialFilter, + spatialFiltersMode, + spatialIndexReferenceViewState, abortController, operationExp, ...params } = options; const {column, operation} = params; + const source = this.getModelSource(filterOwner); + const spatialFiltersResolution = this._getSpatialFiltersResolution( + source, + spatialFilter, + spatialIndexReferenceViewState + ); type FormulaModelResponse = {rows: {value: number}[]}; return executeModel({ model: 'formula', - source: {...this.getModelSource(filterOwner), spatialFilter}, + source: { + ...source, + spatialFiltersResolution, + spatialFiltersMode, + spatialFilter, + }, params: {column: column ?? '*', operation, operationExp}, opts: {abortController}, }).then((res: FormulaModelResponse) => normalizeObjectKeys(res.rows[0])); @@ -185,14 +253,32 @@ export abstract class WidgetBaseSource { async getHistogram( options: HistogramRequestOptions ): Promise { - const {filterOwner, spatialFilter, abortController, ...params} = options; + const { + filterOwner, + spatialFilter, + spatialFiltersMode, + spatialIndexReferenceViewState, + abortController, + ...params + } = options; const {column, operation, ticks} = params; + const source = this.getModelSource(filterOwner); + const spatialFiltersResolution = this._getSpatialFiltersResolution( + source, + spatialFilter, + spatialIndexReferenceViewState + ); type HistogramModelResponse = {rows: {tick: number; value: number}[]}; const data = await executeModel({ model: 'histogram', - source: {...this.getModelSource(filterOwner), spatialFilter}, + source: { + ...source, + spatialFiltersResolution, + spatialFiltersMode, + spatialFilter, + }, params: {column, operation, ticks}, opts: {abortController}, }).then((res: HistogramModelResponse) => normalizeObjectKeys(res.rows)); @@ -220,14 +306,32 @@ export abstract class WidgetBaseSource { * or rendering a range slider UI for filtering. */ async getRange(options: RangeRequestOptions): Promise { - const {filterOwner, spatialFilter, abortController, ...params} = options; + const { + filterOwner, + spatialFilter, + spatialFiltersMode, + spatialIndexReferenceViewState, + abortController, + ...params + } = options; const {column} = params; + const source = this.getModelSource(filterOwner); + const spatialFiltersResolution = this._getSpatialFiltersResolution( + source, + spatialFilter, + spatialIndexReferenceViewState + ); type RangeModelResponse = {rows: {min: number; max: number}[]}; return executeModel({ model: 'range', - source: {...this.getModelSource(filterOwner), spatialFilter}, + source: { + ...source, + spatialFiltersResolution, + spatialFiltersMode, + spatialFilter, + }, params: {column}, opts: {abortController}, }).then((res: RangeModelResponse) => normalizeObjectKeys(res.rows[0])); @@ -242,10 +346,24 @@ export abstract class WidgetBaseSource { * values. Suitable for rendering scatter plots. */ async getScatter(options: ScatterRequestOptions): Promise { - const {filterOwner, spatialFilter, abortController, ...params} = options; + const { + filterOwner, + spatialFilter, + spatialFiltersMode, + spatialIndexReferenceViewState, + abortController, + ...params + } = options; const {xAxisColumn, xAxisJoinOperation, yAxisColumn, yAxisJoinOperation} = params; + const source = this.getModelSource(filterOwner); + const spatialFiltersResolution = this._getSpatialFiltersResolution( + source, + spatialFilter, + spatialIndexReferenceViewState + ); + // Make sure this is sync with the same constant in cloud-native/maps-api const HARD_LIMIT = 500; @@ -253,7 +371,12 @@ export abstract class WidgetBaseSource { return executeModel({ model: 'scatterplot', - source: {...this.getModelSource(filterOwner), spatialFilter}, + source: { + ...source, + spatialFiltersResolution, + spatialFiltersMode, + spatialFilter, + }, params: { xAxisColumn, xAxisJoinOperation, @@ -276,8 +399,21 @@ export abstract class WidgetBaseSource { * sorting. Suitable for displaying tables and lists. */ async getTable(options: TableRequestOptions): Promise { - const {filterOwner, spatialFilter, abortController, ...params} = options; + const { + filterOwner, + spatialFilter, + spatialFiltersMode, + spatialIndexReferenceViewState, + abortController, + ...params + } = options; const {columns, sortBy, sortDirection, offset = 0, limit = 10} = params; + const source = this.getModelSource(filterOwner); + const spatialFiltersResolution = this._getSpatialFiltersResolution( + source, + spatialFilter, + spatialIndexReferenceViewState + ); type TableModelResponse = { rows: Record[]; @@ -286,7 +422,12 @@ export abstract class WidgetBaseSource { return executeModel({ model: 'table', - source: {...this.getModelSource(filterOwner), spatialFilter}, + source: { + ...source, + spatialFiltersResolution, + spatialFiltersMode, + spatialFilter, + }, params: { column: columns, sortBy, @@ -313,7 +454,14 @@ export abstract class WidgetBaseSource { async getTimeSeries( options: TimeSeriesRequestOptions ): Promise { - const {filterOwner, abortController, spatialFilter, ...params} = options; + const { + filterOwner, + abortController, + spatialFilter, + spatialFiltersMode, + spatialIndexReferenceViewState, + ...params + } = options; const { column, operationColumn, @@ -326,6 +474,13 @@ export abstract class WidgetBaseSource { splitByCategoryValues, } = params; + const source = this.getModelSource(filterOwner); + const spatialFiltersResolution = this._getSpatialFiltersResolution( + source, + spatialFilter, + spatialIndexReferenceViewState + ); + type TimeSeriesModelResponse = { rows: {name: string; value: number}[]; metadata: {categories: string[]}; @@ -333,7 +488,12 @@ export abstract class WidgetBaseSource { return executeModel({ model: 'timeseries', - source: {...this.getModelSource(filterOwner), spatialFilter}, + source: { + ...source, + spatialFiltersResolution, + spatialFiltersMode, + spatialFilter, + }, params: { column, stepSize,