@carto/api-client 0.5.0-alpha.7 → 0.5.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,3 @@
1
- /* eslint-disable @typescript-eslint/require-await */
2
- import {TilesetSourceOptions} from '../sources/index.js';
3
1
  import {
4
2
  CategoryRequestOptions,
5
3
  CategoryResponse,
@@ -17,33 +15,15 @@ import {
17
15
  TimeSeriesRequestOptions,
18
16
  TimeSeriesResponse,
19
17
  } from './types.js';
20
- import {InvalidColumnError, assert, getApplicableFilters} from '../utils.js';
21
- import {TileFormat} from '../constants.js';
22
- import {Filter, SpatialFilter, Tile} from '../types.js';
23
- import {
24
- TileFeatureExtractOptions,
25
- applyFilters,
26
- geojsonFeatures,
27
- tileFeatures,
28
- } from '../filters/index.js';
29
- import {
30
- aggregationFunctions,
31
- applySorting,
32
- groupValuesByColumn,
33
- groupValuesByDateColumn,
34
- histogram,
35
- scatterPlot,
36
- } from '../operations/index.js';
37
- import {FeatureData} from '../types-internal.js';
18
+ import {SpatialFilter, Tile} from '../types.js';
19
+ import {TileFeatureExtractOptions} from '../filters/index.js';
38
20
  import {FeatureCollection} from 'geojson';
39
- import {SpatialDataType} from '../sources/types.js';
40
21
  import {WidgetSource, WidgetSourceProps} from './widget-source.js';
41
- import {booleanEqual} from '@turf/boolean-equal';
42
-
43
- // TODO(cleanup): Parameter defaults in source functions and widget API calls are
44
- // currently duplicated and possibly inconsistent. Consider consolidating and
45
- // operating on Required<T> objects. See:
46
- // https://github.com/CartoDB/carto-api-client/issues/39
22
+ import {Method} from '../workers/constants.js';
23
+ import {WorkerRequest, WorkerResponse} from '../workers/types.js';
24
+ import {SpatialDataType, TilesetSourceOptions} from '../sources/types.js';
25
+ import {TileFormat} from '../constants.js';
26
+ import {WidgetTilesetSourceImpl} from './widget-tileset-source-impl.js';
47
27
 
48
28
  export type WidgetTilesetSourceProps = WidgetSourceProps &
49
29
  Omit<TilesetSourceOptions, 'filters'> & {
@@ -76,11 +56,136 @@ export type WidgetTilesetSourceResult = {widgetSource: WidgetTilesetSource};
76
56
  * ```
77
57
  */
78
58
  export class WidgetTilesetSource extends WidgetSource<WidgetTilesetSourceProps> {
79
- private _tiles: Tile[] = [];
80
- private _features: FeatureData[] = [];
81
- private _tileFeatureExtractOptions: TileFeatureExtractOptions = {};
82
- private _tileFeatureExtractPreviousInputs: {spatialFilter?: SpatialFilter} =
83
- {};
59
+ protected _localImpl: WidgetTilesetSourceImpl | null = null;
60
+
61
+ protected _workerImpl: Worker | null = null;
62
+ protected _workerEnabled: boolean;
63
+ protected _workerNextRequestId = 1;
64
+
65
+ constructor(props: WidgetTilesetSourceProps) {
66
+ super(props);
67
+
68
+ this._workerEnabled =
69
+ (props.widgetSourceWorker ?? true) &&
70
+ TSUP_FORMAT !== 'cjs' &&
71
+ typeof Worker !== 'undefined';
72
+
73
+ if (!this._workerEnabled) {
74
+ this._localImpl = new WidgetTilesetSourceImpl(this.props);
75
+ }
76
+ }
77
+
78
+ destroy() {
79
+ this._localImpl?.destroy();
80
+ this._localImpl = null;
81
+
82
+ this._workerImpl?.terminate();
83
+ this._workerImpl = null;
84
+
85
+ super.destroy();
86
+ }
87
+
88
+ /////////////////////////////////////////////////////////////////////////////
89
+ // WEB WORKER MANAGEMENT
90
+
91
+ /**
92
+ * Returns an initialized Worker, to be reused for the lifecycle of this
93
+ * source instance.
94
+ */
95
+ protected _getWorker(): Worker {
96
+ if (this._workerImpl) {
97
+ return this._workerImpl;
98
+ }
99
+
100
+ this._workerImpl = new Worker(
101
+ new URL('@carto/api-client/worker', import.meta.url),
102
+ {
103
+ type: 'module',
104
+ name: 'cartowidgettileset',
105
+ }
106
+ );
107
+
108
+ this._workerImpl.postMessage({
109
+ method: Method.INIT,
110
+ params: [this.props],
111
+ } as WorkerRequest);
112
+
113
+ return this._workerImpl;
114
+ }
115
+
116
+ /** Executes a given method on the worker. */
117
+ protected _executeWorkerMethod<T>(
118
+ method: Method,
119
+ params: unknown[],
120
+ signal?: AbortSignal
121
+ ): Promise<T> {
122
+ if (!this._workerEnabled) {
123
+ // @ts-expect-error No type-checking dynamic method name.
124
+ return this._localImpl[method](...params);
125
+ }
126
+
127
+ const worker = this._getWorker();
128
+ const requestId = this._workerNextRequestId++;
129
+
130
+ // TODO: ViewState may contain non-serializable data, which we do not need.
131
+ // Remove this sanitization after sc-469614 is fixed.
132
+ const options = params[0] as any;
133
+ if (options?.spatialIndexReferenceViewState) {
134
+ const {zoom, latitude, longitude} =
135
+ options.spatialIndexReferenceViewState;
136
+ options.spatialIndexReferenceViewState = {zoom, latitude, longitude};
137
+ }
138
+
139
+ let resolve: ((value: T) => void) | null = null;
140
+ let reject: ((reason: any) => void) | null = null;
141
+
142
+ // If worker sends message to main process, check whether it's a response
143
+ // to this request, and whether the request can been aborted. Then resolve
144
+ // or reject the Promise.
145
+ function onMessage(e: MessageEvent) {
146
+ const response = e.data as WorkerResponse;
147
+ if (response.requestId !== requestId) return;
148
+
149
+ if (signal?.aborted) {
150
+ reject!(new Error(signal.reason));
151
+ } else if (response.ok) {
152
+ resolve!(response.result as T);
153
+ } else {
154
+ reject!(new Error(response.error));
155
+ }
156
+ }
157
+
158
+ // If request is aborted by user, immediately reject the Promise.
159
+ function onAbort() {
160
+ reject!(new Error(signal!.reason));
161
+ }
162
+
163
+ worker.addEventListener('message', onMessage);
164
+ signal?.addEventListener('abort', onAbort);
165
+
166
+ // Send the task to the worker, creating a Promise to resolve/reject later.
167
+ const promise = new Promise<T>((_resolve, _reject) => {
168
+ resolve = _resolve;
169
+ reject = _reject;
170
+
171
+ worker.postMessage({
172
+ requestId,
173
+ method,
174
+ params,
175
+ } as WorkerRequest);
176
+ });
177
+
178
+ // Whether the task completes, fails, or aborts: clean up afterward.
179
+ void promise.finally(() => {
180
+ worker.removeEventListener('message', onMessage);
181
+ signal?.removeEventListener('abort', onAbort);
182
+ });
183
+
184
+ return promise;
185
+ }
186
+
187
+ /////////////////////////////////////////////////////////////////////////////
188
+ // DATA LOADING
84
189
 
85
190
  /**
86
191
  * Loads features as a list of tiles (typically provided by deck.gl).
@@ -88,39 +193,36 @@ export class WidgetTilesetSource extends WidgetSource<WidgetTilesetSourceProps>
88
193
  * before computing statistics on the tiles.
89
194
  */
90
195
  loadTiles(tiles: unknown[]) {
91
- this._tiles = tiles as Tile[];
92
- this._features.length = 0;
196
+ if (!this._workerEnabled) {
197
+ return this._localImpl!.loadTiles(tiles);
198
+ }
199
+
200
+ const worker = this._getWorker();
201
+
202
+ tiles = (tiles as Tile[]).map(({id, bbox, data}) => ({
203
+ id,
204
+ bbox,
205
+ data,
206
+ }));
207
+
208
+ worker.postMessage({
209
+ method: Method.LOAD_TILES,
210
+ params: [tiles],
211
+ } as WorkerRequest);
93
212
  }
94
213
 
95
214
  /** Configures options used to extract features from tiles. */
96
215
  setTileFeatureExtractOptions(options: TileFeatureExtractOptions) {
97
- this._tileFeatureExtractOptions = options;
98
- this._features.length = 0;
99
- }
100
-
101
- protected _extractTileFeatures(spatialFilter: SpatialFilter) {
102
- // When spatial filter has not changed, don't redo extraction. If tiles or
103
- // tile extract options change, features will have been cleared already.
104
- const prevInputs = this._tileFeatureExtractPreviousInputs;
105
- if (
106
- this._features.length &&
107
- prevInputs.spatialFilter &&
108
- booleanEqual(prevInputs.spatialFilter, spatialFilter)
109
- ) {
110
- return;
216
+ if (!this._workerEnabled) {
217
+ return this._localImpl?.setTileFeatureExtractOptions(options);
111
218
  }
112
219
 
113
- this._features = tileFeatures({
114
- tiles: this._tiles,
115
- tileFormat: this.props.tileFormat,
116
- ...this._tileFeatureExtractOptions,
220
+ const worker = this._getWorker();
117
221
 
118
- spatialFilter,
119
- spatialDataColumn: this.props.spatialDataColumn,
120
- spatialDataType: this.props.spatialDataType,
222
+ worker.postMessage({
223
+ type: Method.SET_TILE_FEATURE_EXTRACT_OPTIONS,
224
+ params: [options],
121
225
  });
122
-
123
- prevInputs.spatialFilter = spatialFilter;
124
226
  }
125
227
 
126
228
  /**
@@ -135,310 +237,72 @@ export class WidgetTilesetSource extends WidgetSource<WidgetTilesetSourceProps>
135
237
  geojson: FeatureCollection;
136
238
  spatialFilter: SpatialFilter;
137
239
  }) {
138
- this._features = geojsonFeatures({
139
- geojson,
140
- spatialFilter,
141
- ...this._tileFeatureExtractOptions,
142
- });
143
- this._tileFeatureExtractPreviousInputs.spatialFilter = spatialFilter;
240
+ if (!this._workerEnabled) {
241
+ return this._localImpl!.loadGeoJSON({geojson, spatialFilter});
242
+ }
243
+
244
+ const worker = this._getWorker();
245
+
246
+ worker.postMessage({
247
+ method: Method.LOAD_GEOJSON,
248
+ params: [{geojson, spatialFilter}],
249
+ } as WorkerRequest);
144
250
  }
145
251
 
252
+ /////////////////////////////////////////////////////////////////////////////
253
+ // WIDGETS API
254
+
255
+ // eslint-disable-next-line @typescript-eslint/require-await
146
256
  override async getFeatures(): Promise<FeaturesResponse> {
147
257
  throw new Error('getFeatures not supported for tilesets');
148
258
  }
149
259
 
150
260
  async getFormula({
151
- column = '*',
152
- operation = 'count',
153
- joinOperation,
154
- filters,
155
- filterOwner,
156
- spatialFilter,
261
+ signal,
262
+ ...options
157
263
  }: FormulaRequestOptions): Promise<FormulaResponse> {
158
- if (operation === 'custom') {
159
- throw new Error('Custom aggregation not supported for tilesets');
160
- }
161
-
162
- // Column is required except when operation is 'count'.
163
- if ((column && column !== '*') || operation !== 'count') {
164
- assertColumn(this._features, column);
165
- }
166
-
167
- const filteredFeatures = this._getFilteredFeatures(
168
- spatialFilter,
169
- filters,
170
- filterOwner
171
- );
172
-
173
- if (filteredFeatures.length === 0 && operation !== 'count') {
174
- return {value: null};
175
- }
176
-
177
- const targetOperation = aggregationFunctions[operation];
178
- return {
179
- value: targetOperation(filteredFeatures, column, joinOperation),
180
- };
264
+ return this._executeWorkerMethod(Method.GET_FORMULA, [options], signal);
181
265
  }
182
266
 
183
267
  override async getHistogram({
184
- operation = 'count',
185
- ticks,
186
- column,
187
- joinOperation,
188
- filters,
189
- filterOwner,
190
- spatialFilter,
268
+ signal,
269
+ ...options
191
270
  }: HistogramRequestOptions): Promise<HistogramResponse> {
192
- const filteredFeatures = this._getFilteredFeatures(
193
- spatialFilter,
194
- filters,
195
- filterOwner
196
- );
197
-
198
- if (!this._features.length) {
199
- return [];
200
- }
201
-
202
- assertColumn(this._features, column);
203
-
204
- return histogram({
205
- data: filteredFeatures,
206
- valuesColumns: normalizeColumns(column),
207
- joinOperation,
208
- ticks,
209
- operation,
210
- });
271
+ return this._executeWorkerMethod(Method.GET_HISTOGRAM, [options], signal);
211
272
  }
212
273
 
213
274
  override async getCategories({
214
- column,
215
- operation = 'count',
216
- operationColumn,
217
- joinOperation,
218
- filters,
219
- filterOwner,
220
- spatialFilter,
275
+ signal,
276
+ ...options
221
277
  }: CategoryRequestOptions): Promise<CategoryResponse> {
222
- const filteredFeatures = this._getFilteredFeatures(
223
- spatialFilter,
224
- filters,
225
- filterOwner
226
- );
227
-
228
- if (!filteredFeatures.length) {
229
- return [];
230
- }
231
-
232
- assertColumn(this._features, column, operationColumn as string);
233
-
234
- const groups = groupValuesByColumn({
235
- data: filteredFeatures,
236
- valuesColumns: normalizeColumns(operationColumn || column),
237
- joinOperation,
238
- keysColumn: column,
239
- operation,
240
- });
241
-
242
- return groups || [];
278
+ return this._executeWorkerMethod(Method.GET_CATEGORIES, [options], signal);
243
279
  }
244
280
 
245
281
  override async getScatter({
246
- xAxisColumn,
247
- yAxisColumn,
248
- xAxisJoinOperation,
249
- yAxisJoinOperation,
250
- filters,
251
- filterOwner,
252
- spatialFilter,
282
+ signal,
283
+ ...options
253
284
  }: ScatterRequestOptions): Promise<ScatterResponse> {
254
- const filteredFeatures = this._getFilteredFeatures(
255
- spatialFilter,
256
- filters,
257
- filterOwner
258
- );
259
-
260
- if (!filteredFeatures.length) {
261
- return [];
262
- }
263
-
264
- assertColumn(this._features, xAxisColumn, yAxisColumn);
265
-
266
- return scatterPlot({
267
- data: filteredFeatures,
268
- xAxisColumns: normalizeColumns(xAxisColumn),
269
- xAxisJoinOperation,
270
- yAxisColumns: normalizeColumns(yAxisColumn),
271
- yAxisJoinOperation,
272
- });
285
+ return this._executeWorkerMethod(Method.GET_SCATTER, [options], signal);
273
286
  }
274
287
 
275
288
  override async getTable({
276
- columns,
277
- searchFilterColumn,
278
- searchFilterText,
279
- sortBy,
280
- sortDirection,
281
- sortByColumnType,
282
- offset = 0,
283
- limit = 10,
284
- filters,
285
- filterOwner,
286
- spatialFilter,
289
+ signal,
290
+ ...options
287
291
  }: TableRequestOptions): Promise<TableResponse> {
288
- // Filter.
289
- let filteredFeatures = this._getFilteredFeatures(
290
- spatialFilter,
291
- filters,
292
- filterOwner
293
- );
294
-
295
- if (!filteredFeatures.length) {
296
- return {rows: [], totalCount: 0};
297
- }
298
-
299
- // Search.
300
- if (searchFilterColumn && searchFilterText) {
301
- filteredFeatures = filteredFeatures.filter(
302
- (row) =>
303
- row[searchFilterColumn] &&
304
- String(row[searchFilterColumn] as unknown)
305
- .toLowerCase()
306
- .includes(String(searchFilterText).toLowerCase())
307
- );
308
- }
309
-
310
- // Sort.
311
- let rows = applySorting(filteredFeatures, {
312
- sortBy,
313
- sortByDirection: sortDirection,
314
- sortByColumnType,
315
- });
316
- const totalCount = rows.length;
317
-
318
- // Offset and limit.
319
- rows = rows.slice(
320
- Math.min(offset, totalCount),
321
- Math.min(offset + limit, totalCount)
322
- );
323
-
324
- // Select columns.
325
- rows = rows.map((srcRow: FeatureData) => {
326
- const dstRow: FeatureData = {};
327
- for (const column of columns) {
328
- dstRow[column] = srcRow[column];
329
- }
330
- return dstRow;
331
- });
332
-
333
- return {rows, totalCount} as TableResponse;
292
+ return this._executeWorkerMethod(Method.GET_TABLE, [options], signal);
334
293
  }
335
294
 
336
295
  override async getTimeSeries({
337
- column,
338
- stepSize,
339
- operation,
340
- operationColumn,
341
- joinOperation,
342
- filters,
343
- filterOwner,
344
- spatialFilter,
296
+ signal,
297
+ ...options
345
298
  }: TimeSeriesRequestOptions): Promise<TimeSeriesResponse> {
346
- const filteredFeatures = this._getFilteredFeatures(
347
- spatialFilter,
348
- filters,
349
- filterOwner
350
- );
351
-
352
- if (!filteredFeatures.length) {
353
- return {rows: []};
354
- }
355
-
356
- assertColumn(this._features, column, operationColumn as string);
357
-
358
- const rows =
359
- groupValuesByDateColumn({
360
- data: filteredFeatures,
361
- valuesColumns: normalizeColumns(operationColumn || column),
362
- keysColumn: column,
363
- groupType: stepSize,
364
- operation,
365
- joinOperation,
366
- }) || [];
367
-
368
- return {rows};
299
+ return this._executeWorkerMethod(Method.GET_TIME_SERIES, [options], signal);
369
300
  }
370
301
 
371
302
  override async getRange({
372
- column,
373
- filters,
374
- filterOwner,
375
- spatialFilter,
303
+ signal,
304
+ ...options
376
305
  }: RangeRequestOptions): Promise<RangeResponse> {
377
- assertColumn(this._features, column);
378
-
379
- const filteredFeatures = this._getFilteredFeatures(
380
- spatialFilter,
381
- filters,
382
- filterOwner
383
- );
384
-
385
- if (!this._features.length) {
386
- // TODO: Is this the only nullable response in the Widgets API? If so,
387
- // can we do something more consistent?
388
- return null;
389
- }
390
-
391
- return {
392
- min: aggregationFunctions.min(filteredFeatures, column),
393
- max: aggregationFunctions.max(filteredFeatures, column),
394
- };
395
- }
396
-
397
- /****************************************************************************
398
- * INTERNAL
399
- */
400
-
401
- private _getFilteredFeatures(
402
- spatialFilter?: SpatialFilter,
403
- filters?: Record<string, Filter>,
404
- filterOwner?: string
405
- ): FeatureData[] {
406
- assert(spatialFilter, 'spatialFilter required for tilesets');
407
- this._extractTileFeatures(spatialFilter);
408
- return applyFilters(
409
- this._features,
410
- getApplicableFilters(filterOwner, filters || this.props.filters),
411
- this.props.filtersLogicalOperator || 'and'
412
- );
413
- }
414
- }
415
-
416
- function assertColumn(
417
- features: FeatureData[],
418
- ...columnArgs: string[] | string[][]
419
- ) {
420
- // TODO(cleanup): Can drop support for multiple column shapes here?
421
-
422
- // Due to the multiple column shape, we normalise it as an array with normalizeColumns
423
- const columns = Array.from(new Set(columnArgs.map(normalizeColumns).flat()));
424
-
425
- const featureKeys = Object.keys(features[0]);
426
-
427
- const invalidColumns = columns.filter(
428
- (column) => !featureKeys.includes(column)
429
- );
430
-
431
- if (invalidColumns.length) {
432
- throw new InvalidColumnError(
433
- `Missing column(s): ${invalidColumns.join(', ')}`
434
- );
306
+ return this._executeWorkerMethod(Method.GET_RANGE, [options], signal);
435
307
  }
436
308
  }
437
-
438
- function normalizeColumns(columns: string | string[]): string[] {
439
- return Array.isArray(columns)
440
- ? columns
441
- : typeof columns === 'string'
442
- ? [columns]
443
- : [];
444
- }
@@ -1,24 +1,25 @@
1
- import {
2
- WidgetTilesetSource,
3
- type WidgetTilesetSourceProps,
4
- } from '../widget-sources/widget-tileset-source.js';
1
+ import {WidgetTilesetSourceImpl} from '../widget-sources/widget-tileset-source-impl.js';
2
+ import {type WidgetTilesetSourceProps} from '../widget-sources/widget-tileset-source.js';
5
3
  import {Method} from './constants.js';
6
4
  import type {WorkerRequest, WorkerResponse} from './types.js';
7
5
 
8
6
  /*
9
7
  * Web Worker, compiled as a separate `@carto/api-client/worker` entrypoint.
10
8
  *
11
- * Workers are scoped to the lifecycle of a single WidgetTilesetWorkerSource
12
- * instance, representing and executing calculations on a single datasource.
9
+ * Workers are scoped to the lifecycle of a single WidgetTilesetSource instance,
10
+ * representing and executing calculations on a single datasource.
13
11
  */
14
12
 
15
- let source: WidgetTilesetSource;
13
+ let source: WidgetTilesetSourceImpl;
16
14
 
17
15
  addEventListener('message', (e) => {
18
16
  const {method, params, requestId} = e.data as WorkerRequest;
19
17
 
20
18
  if (method === Method.INIT) {
21
- source = new WidgetTilesetSource(params[0] as WidgetTilesetSourceProps);
19
+ source = new WidgetTilesetSourceImpl({
20
+ ...(params[0] as WidgetTilesetSourceProps),
21
+ widgetSourceWorker: false,
22
+ });
22
23
  return;
23
24
  }
24
25