@carto/api-client 0.5.0-alpha.6 → 0.5.0-alpha.8

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) && TSUP_FORMAT !== 'cjs';
70
+ this._localImpl = this._workerEnabled
71
+ ? null
72
+ : new WidgetTilesetSourceImpl(this.props);
73
+ }
74
+
75
+ destroy() {
76
+ this._localImpl?.destroy();
77
+ this._localImpl = null;
78
+
79
+ this._workerImpl?.terminate();
80
+ this._workerImpl = null;
81
+
82
+ super.destroy();
83
+ }
84
+
85
+ /////////////////////////////////////////////////////////////////////////////
86
+ // WEB WORKER MANAGEMENT
87
+
88
+ /**
89
+ * Returns an initialized Worker, to be reused for the lifecycle of this
90
+ * source instance.
91
+ */
92
+ protected _getWorker(): Worker | null {
93
+ if (this._workerImpl || this._localImpl) {
94
+ return this._workerImpl;
95
+ }
96
+
97
+ try {
98
+ this._workerImpl = new Worker(new URL('worker.js', import.meta.url), {
99
+ type: 'module',
100
+ name: 'cartowidgettileset',
101
+ });
102
+
103
+ this._workerImpl.postMessage({
104
+ method: Method.INIT,
105
+ params: [this.props],
106
+ } as WorkerRequest);
107
+
108
+ return this._workerImpl;
109
+ } catch {
110
+ this._workerEnabled = false;
111
+ this._localImpl = new WidgetTilesetSourceImpl(this.props);
112
+ return null;
113
+ }
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
+ const worker = this._getWorker();
123
+ if (!worker) {
124
+ // @ts-expect-error No type-checking dynamic method name.
125
+ return this._localImpl[method](...params);
126
+ }
127
+
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,34 @@ 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
+ const worker = this._getWorker();
197
+ if (!worker) {
198
+ return this._localImpl!.loadTiles(tiles);
199
+ }
200
+
201
+ tiles = (tiles as Tile[]).map(({id, bbox, data}) => ({
202
+ id,
203
+ bbox,
204
+ data,
205
+ }));
206
+
207
+ worker.postMessage({
208
+ method: Method.LOAD_TILES,
209
+ params: [tiles],
210
+ } as WorkerRequest);
93
211
  }
94
212
 
95
213
  /** Configures options used to extract features from tiles. */
96
214
  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;
215
+ const worker = this._getWorker();
216
+ if (!worker) {
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,
117
-
118
- spatialFilter,
119
- spatialDataColumn: this.props.spatialDataColumn,
120
- spatialDataType: this.props.spatialDataType,
220
+ worker.postMessage({
221
+ type: Method.SET_TILE_FEATURE_EXTRACT_OPTIONS,
222
+ params: [options],
121
223
  });
122
-
123
- prevInputs.spatialFilter = spatialFilter;
124
224
  }
125
225
 
126
226
  /**
@@ -135,310 +235,71 @@ export class WidgetTilesetSource extends WidgetSource<WidgetTilesetSourceProps>
135
235
  geojson: FeatureCollection;
136
236
  spatialFilter: SpatialFilter;
137
237
  }) {
138
- this._features = geojsonFeatures({
139
- geojson,
140
- spatialFilter,
141
- ...this._tileFeatureExtractOptions,
142
- });
143
- this._tileFeatureExtractPreviousInputs.spatialFilter = spatialFilter;
238
+ const worker = this._getWorker();
239
+ if (!worker) {
240
+ return this._localImpl!.loadGeoJSON({geojson, spatialFilter});
241
+ }
242
+
243
+ worker.postMessage({
244
+ method: Method.LOAD_GEOJSON,
245
+ params: [{geojson, spatialFilter}],
246
+ } as WorkerRequest);
144
247
  }
145
248
 
249
+ /////////////////////////////////////////////////////////////////////////////
250
+ // WIDGETS API
251
+
252
+ // eslint-disable-next-line @typescript-eslint/require-await
146
253
  override async getFeatures(): Promise<FeaturesResponse> {
147
254
  throw new Error('getFeatures not supported for tilesets');
148
255
  }
149
256
 
150
257
  async getFormula({
151
- column = '*',
152
- operation = 'count',
153
- joinOperation,
154
- filters,
155
- filterOwner,
156
- spatialFilter,
258
+ signal,
259
+ ...options
157
260
  }: 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
- };
261
+ return this._executeWorkerMethod(Method.GET_FORMULA, [options], signal);
181
262
  }
182
263
 
183
264
  override async getHistogram({
184
- operation = 'count',
185
- ticks,
186
- column,
187
- joinOperation,
188
- filters,
189
- filterOwner,
190
- spatialFilter,
265
+ signal,
266
+ ...options
191
267
  }: 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
- });
268
+ return this._executeWorkerMethod(Method.GET_HISTOGRAM, [options], signal);
211
269
  }
212
270
 
213
271
  override async getCategories({
214
- column,
215
- operation = 'count',
216
- operationColumn,
217
- joinOperation,
218
- filters,
219
- filterOwner,
220
- spatialFilter,
272
+ signal,
273
+ ...options
221
274
  }: 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 || [];
275
+ return this._executeWorkerMethod(Method.GET_CATEGORIES, [options], signal);
243
276
  }
244
277
 
245
278
  override async getScatter({
246
- xAxisColumn,
247
- yAxisColumn,
248
- xAxisJoinOperation,
249
- yAxisJoinOperation,
250
- filters,
251
- filterOwner,
252
- spatialFilter,
279
+ signal,
280
+ ...options
253
281
  }: 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
- });
282
+ return this._executeWorkerMethod(Method.GET_SCATTER, [options], signal);
273
283
  }
274
284
 
275
285
  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,
286
+ signal,
287
+ ...options
287
288
  }: 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;
289
+ return this._executeWorkerMethod(Method.GET_TABLE, [options], signal);
334
290
  }
335
291
 
336
292
  override async getTimeSeries({
337
- column,
338
- stepSize,
339
- operation,
340
- operationColumn,
341
- joinOperation,
342
- filters,
343
- filterOwner,
344
- spatialFilter,
293
+ signal,
294
+ ...options
345
295
  }: 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};
296
+ return this._executeWorkerMethod(Method.GET_TIME_SERIES, [options], signal);
369
297
  }
370
298
 
371
299
  override async getRange({
372
- column,
373
- filters,
374
- filterOwner,
375
- spatialFilter,
300
+ signal,
301
+ ...options
376
302
  }: 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
- };
303
+ return this._executeWorkerMethod(Method.GET_RANGE, [options], signal);
395
304
  }
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
- );
435
- }
436
- }
437
-
438
- function normalizeColumns(columns: string | string[]): string[] {
439
- return Array.isArray(columns)
440
- ? columns
441
- : typeof columns === 'string'
442
- ? [columns]
443
- : [];
444
305
  }
@@ -8,8 +8,8 @@ import type {WorkerRequest, WorkerResponse} from './types.js';
8
8
  /*
9
9
  * Web Worker, compiled as a separate `@carto/api-client/worker` entrypoint.
10
10
  *
11
- * Workers are scoped to the lifecycle of a single WidgetTilesetWorkerSource
12
- * instance, representing and executing calculations on a single datasource.
11
+ * Workers are scoped to the lifecycle of a single WidgetTilesetSource instance,
12
+ * representing and executing calculations on a single datasource.
13
13
  */
14
14
 
15
15
  let source: WidgetTilesetSource;
@@ -18,7 +18,10 @@ addEventListener('message', (e) => {
18
18
  const {method, params, requestId} = e.data as WorkerRequest;
19
19
 
20
20
  if (method === Method.INIT) {
21
- source = new WidgetTilesetSource(params[0] as WidgetTilesetSourceProps);
21
+ source = new WidgetTilesetSource({
22
+ ...(params[0] as WidgetTilesetSourceProps),
23
+ widgetSourceWorker: false,
24
+ });
22
25
  return;
23
26
  }
24
27