@carto/api-client 0.5.7-alpha-optional-spatial-filter.1 → 0.5.7-alpha-others-orderby.1

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.
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "homepage": "https://github.com/CartoDB/carto-api-client#readme",
9
9
  "author": "Don McCurdy <donmccurdy@carto.com>",
10
10
  "packageManager": "yarn@4.3.1",
11
- "version": "0.5.7-alpha-optional-spatial-filter.1",
11
+ "version": "0.5.7-alpha-others-orderby.1",
12
12
  "license": "MIT",
13
13
  "publishConfig": {
14
14
  "access": "public"
@@ -19,7 +19,7 @@ export type TileFeatures = {
19
19
  tileFormat: TileFormat;
20
20
  spatialDataType: SpatialDataType;
21
21
  spatialDataColumn?: string;
22
- spatialFilter?: SpatialFilter;
22
+ spatialFilter: SpatialFilter;
23
23
  uniqueIdProperty?: string;
24
24
  rasterMetadata?: RasterMetadata;
25
25
  storeGeometry?: boolean;
@@ -1,15 +1,22 @@
1
+ import bboxPolygon from '@turf/bbox-polygon';
1
2
  import intersects from '@turf/boolean-intersects';
3
+ import booleanWithin from '@turf/boolean-within';
4
+ import intersect from '@turf/intersect';
5
+ import {transformToTileCoords} from '../utils/transformToTileCoords.js';
2
6
  import {transformTileCoordsToWGS84} from '../utils/transformTileCoordsToWGS84.js';
3
7
  import {TileFormat} from '../constants.js';
4
8
  import type {
5
9
  BBox,
10
+ Feature,
6
11
  Geometry,
7
12
  LineString,
13
+ MultiPolygon,
8
14
  Point,
9
15
  Polygon,
10
16
  Position,
11
17
  } from 'geojson';
12
18
  import type {SpatialFilter, Tile} from '../types.js';
19
+ import {featureCollection} from '@turf/helpers';
13
20
  import type {FeatureData} from '../types-internal.js';
14
21
  import type {
15
22
  BinaryAttribute,
@@ -20,7 +27,6 @@ import type {
20
27
  BinaryPolygonFeature,
21
28
  TypedArrayConstructor,
22
29
  } from '@loaders.gl/schema';
23
- import {intersectTileGeometry} from './tileIntersection.js';
24
30
 
25
31
  export const FEATURE_GEOM_PROPERTY = '__geomValue';
26
32
 
@@ -45,7 +51,7 @@ export function tileFeaturesGeometries({
45
51
  }: {
46
52
  tiles: Tile[];
47
53
  tileFormat?: TileFormat;
48
- spatialFilter?: SpatialFilter;
54
+ spatialFilter: SpatialFilter;
49
55
  uniqueIdProperty?: string;
50
56
  options?: GeometryExtractOptions;
51
57
  }): FeatureData[] {
@@ -58,50 +64,65 @@ export function tileFeaturesGeometries({
58
64
  continue;
59
65
  }
60
66
 
61
- const tileBbox = [
67
+ const bbox = [
62
68
  tile.bbox.west,
63
69
  tile.bbox.south,
64
70
  tile.bbox.east,
65
71
  tile.bbox.north,
66
72
  ] as BBox;
67
-
68
- const intersection = intersectTileGeometry(
69
- tileBbox,
70
- tileFormat,
71
- spatialFilter
73
+ const bboxToGeom = bboxPolygon(bbox);
74
+ const tileIsFullyVisible = booleanWithin(bboxToGeom, spatialFilter);
75
+
76
+ // Clip the geometry to intersect with the tile
77
+ const spatialFilterFeature: Feature<Polygon | MultiPolygon> = {
78
+ type: 'Feature',
79
+ geometry: spatialFilter,
80
+ properties: {},
81
+ };
82
+ const clippedGeometryToIntersect = intersect(
83
+ featureCollection([bboxToGeom, spatialFilterFeature])
72
84
  );
73
85
 
74
- if (intersection === false) continue;
86
+ if (!clippedGeometryToIntersect) {
87
+ continue;
88
+ }
75
89
 
76
- const transformedSpatialFilter =
77
- intersection === true ? undefined : intersection;
90
+ // We assume that MVT tileFormat uses local coordinates so we transform the geometry to intersect to tile coordinates [0..1],
91
+ // while in the case of 'geojson' or binary, the geometries are already in WGS84
92
+ const transformedGeometryToIntersect =
93
+ tileFormat === TileFormat.MVT
94
+ ? transformToTileCoords(clippedGeometryToIntersect.geometry, bbox)
95
+ : clippedGeometryToIntersect.geometry;
78
96
 
79
97
  calculateFeatures({
80
98
  map,
81
- spatialFilter: transformedSpatialFilter,
99
+ tileIsFullyVisible,
100
+ geometryIntersection: transformedGeometryToIntersect,
82
101
  data: tile.data.points!,
83
102
  type: 'Point',
84
- bbox: tileBbox,
103
+ bbox,
85
104
  tileFormat,
86
105
  uniqueIdProperty,
87
106
  options,
88
107
  });
89
108
  calculateFeatures({
90
109
  map,
91
- spatialFilter: transformedSpatialFilter,
110
+ tileIsFullyVisible,
111
+ geometryIntersection: transformedGeometryToIntersect,
92
112
  data: tile.data.lines!,
93
113
  type: 'LineString',
94
- bbox: tileBbox,
114
+ bbox,
95
115
  tileFormat,
96
116
  uniqueIdProperty,
97
117
  options,
98
118
  });
99
119
  calculateFeatures({
100
120
  map,
101
- spatialFilter: transformedSpatialFilter,
121
+ tileIsFullyVisible,
122
+ geometryIntersection: transformedGeometryToIntersect,
102
123
  data: tile.data.polygons!,
103
124
  type: 'Polygon',
104
- bbox: tileBbox,
125
+ bbox,
105
126
  tileFormat,
106
127
  uniqueIdProperty,
107
128
  options,
@@ -120,7 +141,7 @@ function processTileFeatureProperties({
120
141
  tileFormat,
121
142
  uniqueIdProperty,
122
143
  storeGeometry,
123
- spatialFilter,
144
+ geometryIntersection,
124
145
  }: {
125
146
  map: TileMap;
126
147
  data: BinaryFeature;
@@ -131,7 +152,7 @@ function processTileFeatureProperties({
131
152
  tileFormat?: TileFormat;
132
153
  uniqueIdProperty?: string;
133
154
  storeGeometry: boolean;
134
- spatialFilter?: Geometry;
155
+ geometryIntersection?: Geometry;
135
156
  }) {
136
157
  const tileProps = getPropertiesFromTile(data, startIndex);
137
158
  const uniquePropertyValue = getUniquePropertyValue(
@@ -146,7 +167,7 @@ function processTileFeatureProperties({
146
167
  let geometry: Geometry | null = null;
147
168
 
148
169
  // Only calculate geometry if necessary
149
- if (storeGeometry || spatialFilter) {
170
+ if (storeGeometry || geometryIntersection) {
150
171
  const {positions} = data;
151
172
  const ringCoordinates = getRingCoordinatesFor(
152
173
  startIndex,
@@ -157,7 +178,11 @@ function processTileFeatureProperties({
157
178
  }
158
179
 
159
180
  // If intersection is required, check before proceeding
160
- if (geometry && spatialFilter && !intersects(geometry, spatialFilter)) {
181
+ if (
182
+ geometry &&
183
+ geometryIntersection &&
184
+ !intersects(geometry, geometryIntersection)
185
+ ) {
161
186
  return;
162
187
  }
163
188
 
@@ -176,7 +201,7 @@ function processTileFeatureProperties({
176
201
  function addIntersectedFeaturesInTile({
177
202
  map,
178
203
  data,
179
- spatialFilter,
204
+ geometryIntersection,
180
205
  type,
181
206
  bbox,
182
207
  tileFormat,
@@ -185,7 +210,7 @@ function addIntersectedFeaturesInTile({
185
210
  }: {
186
211
  map: TileMap;
187
212
  data: BinaryFeature;
188
- spatialFilter: Geometry;
213
+ geometryIntersection: Geometry;
189
214
  type: BinaryGeometryType;
190
215
  bbox: BBox;
191
216
  tileFormat?: TileFormat;
@@ -208,7 +233,7 @@ function addIntersectedFeaturesInTile({
208
233
  tileFormat,
209
234
  uniqueIdProperty,
210
235
  storeGeometry,
211
- spatialFilter,
236
+ geometryIntersection,
212
237
  });
213
238
  }
214
239
  }
@@ -325,7 +350,8 @@ function getRingCoordinatesFor(
325
350
 
326
351
  function calculateFeatures({
327
352
  map,
328
- spatialFilter,
353
+ tileIsFullyVisible,
354
+ geometryIntersection,
329
355
  data,
330
356
  type,
331
357
  bbox,
@@ -334,7 +360,8 @@ function calculateFeatures({
334
360
  options,
335
361
  }: {
336
362
  map: TileMap;
337
- spatialFilter?: SpatialFilter;
363
+ tileIsFullyVisible: boolean;
364
+ geometryIntersection: SpatialFilter;
338
365
  data: BinaryFeature;
339
366
  type: BinaryGeometryType;
340
367
  bbox: BBox;
@@ -346,7 +373,7 @@ function calculateFeatures({
346
373
  return;
347
374
  }
348
375
 
349
- if (!spatialFilter) {
376
+ if (tileIsFullyVisible) {
350
377
  addAllFeaturesInTile({
351
378
  map,
352
379
  data,
@@ -360,7 +387,7 @@ function calculateFeatures({
360
387
  addIntersectedFeaturesInTile({
361
388
  map,
362
389
  data,
363
- spatialFilter,
390
+ geometryIntersection,
364
391
  type,
365
392
  bbox,
366
393
  tileFormat,
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  cellToChildren as _cellToChildren,
3
+ cellToBoundary,
3
4
  cellToTile,
5
+ geometryToCells,
4
6
  getResolution,
5
7
  } from 'quadbin';
6
8
  import type {RasterTile, SpatialFilter, Tile} from '../types.js';
@@ -10,11 +12,13 @@ import type {
10
12
  RasterMetadataBand,
11
13
  SpatialDataType,
12
14
  } from '../sources/types.js';
13
- import {intersectTileRaster} from './tileIntersection.js';
15
+ import booleanWithin from '@turf/boolean-within';
16
+ import intersect from '@turf/intersect';
17
+ import {feature, featureCollection} from '@turf/helpers';
14
18
 
15
19
  export type TileFeaturesRasterOptions = {
16
20
  tiles: RasterTile[];
17
- spatialFilter?: SpatialFilter;
21
+ spatialFilter: SpatialFilter;
18
22
  spatialDataColumn: string;
19
23
  spatialDataType: SpatialDataType;
20
24
  rasterMetadata: RasterMetadata;
@@ -48,20 +52,27 @@ export function tileFeaturesRaster({
48
52
  for (const tile of tiles as Required<RasterTile>[]) {
49
53
  const parent = tile.index.q;
50
54
 
51
- const intersection = intersectTileRaster(
52
- parent,
53
- cellResolution,
54
- options.spatialFilter
55
+ // If tile is partially overlapping with the spatial filter, compute a quadbin covering for the
56
+ // spatial filter + tile intersection, at cell resolution. If tile is fully inside or outside
57
+ // the spatial filter, computing a covering can be skipped. Avoid computing a quadbin covering
58
+ // for the _entire_ spatial filter, which may contain far too many cells (e.g. at zoom=3 and
59
+ // resolution=14, we have ~18,000,000 cells in the viewport.)
60
+ const tilePolygon = cellToBoundary(parent);
61
+ const tileFilter = intersect(
62
+ featureCollection([feature(tilePolygon), feature(options.spatialFilter)])
55
63
  );
56
-
57
- if (intersection === false) continue;
58
-
64
+ const needsFilter = tileFilter
65
+ ? !booleanWithin(tilePolygon, options.spatialFilter)
66
+ : false;
67
+ const tileFilterCells = needsFilter
68
+ ? new Set(geometryToCells(tileFilter!.geometry, cellResolution))
69
+ : null;
59
70
  const tileSortedCells = cellToChildrenSorted(parent, cellResolution);
60
71
 
61
72
  // For each pixel/cell within the spatial filter, create a FeatureData.
62
73
  // Order is row-major, starting from NW and ending at SE.
63
74
  for (let i = 0; i < tileSortedCells.length; i++) {
64
- if (intersection !== true && !intersection.has(tileSortedCells[i])) {
75
+ if (needsFilter && !tileFilterCells!.has(tileSortedCells[i])) {
65
76
  continue;
66
77
  }
67
78
 
@@ -1,15 +1,15 @@
1
1
  import {SpatialIndex} from '../constants.js';
2
- import {getResolution as quadbinGetResolution} from 'quadbin';
2
+ import {getResolution as quadbinGetResolution, geometryToCells} from 'quadbin';
3
+ import bboxClip from '@turf/bbox-clip';
3
4
  import type {SpatialFilter, SpatialIndexTile} from '../types.js';
4
- import type {Feature} from 'geojson';
5
- import {getResolution as h3GetResolution} from 'h3-js';
5
+ import type {BBox, Feature} from 'geojson';
6
+ import {getResolution as h3GetResolution, polygonToCells} from 'h3-js';
6
7
  import type {FeatureData} from '../types-internal.js';
7
8
  import type {SpatialDataType} from '../sources/types.js';
8
- import {intersectTileH3, intersectTileQuadbin} from './tileIntersection.js';
9
9
 
10
10
  export type TileFeaturesSpatialIndexOptions = {
11
11
  tiles: SpatialIndexTile[];
12
- spatialFilter?: SpatialFilter;
12
+ spatialFilter: SpatialFilter;
13
13
  spatialDataColumn: string;
14
14
  spatialDataType: SpatialDataType;
15
15
  };
@@ -22,41 +22,30 @@ export function tileFeaturesSpatialIndex({
22
22
  }: TileFeaturesSpatialIndexOptions): FeatureData[] {
23
23
  const map = new Map();
24
24
  const spatialIndex = getSpatialIndex(spatialDataType);
25
- const cellResolution = getResolution(tiles, spatialIndex);
25
+ const resolution = getResolution(tiles, spatialIndex);
26
26
  const spatialIndexIDName = spatialDataColumn
27
27
  ? spatialDataColumn
28
28
  : spatialIndex;
29
29
 
30
- if (!cellResolution) {
30
+ if (!resolution) {
31
31
  return [];
32
32
  }
33
+ const cells = getCellsCoverGeometry(spatialFilter, spatialIndex, resolution);
33
34
 
34
- let intersection: undefined | boolean | Set<bigint | string>;
35
-
36
- // Compute H3 intersection globally, Quadbin intersection per-tile. See tileIntersection.ts.
37
- if (spatialIndex === SpatialIndex.H3) {
38
- intersection = intersectTileH3(cellResolution as number, spatialFilter);
35
+ if (!cells?.length) {
36
+ return [];
39
37
  }
40
38
 
39
+ // We transform cells to Set to improve the performace
40
+ const cellsSet = new Set<bigint | string>(cells);
41
+
41
42
  for (const tile of tiles) {
42
43
  if (tile.isVisible === false || !tile.data) {
43
44
  continue;
44
45
  }
45
46
 
46
- if (spatialIndex === SpatialIndex.QUADBIN) {
47
- const parent = getTileIndex(tile, spatialIndex);
48
- intersection = intersectTileQuadbin(
49
- parent as bigint,
50
- cellResolution as bigint,
51
- spatialFilter
52
- );
53
- }
54
-
55
- if (!intersection) continue;
56
-
57
47
  tile.data.forEach((d: Feature) => {
58
- // @ts-expect-error Mixed types for cell indices.
59
- if (intersection === true || intersection.has(d.id as bigint | string)) {
48
+ if (cellsSet.has(d.id as bigint | string)) {
60
49
  map.set(d.id, {...d.properties, [spatialIndexIDName]: d.id});
61
50
  }
62
51
  });
@@ -64,21 +53,10 @@ export function tileFeaturesSpatialIndex({
64
53
  return Array.from(map.values());
65
54
  }
66
55
 
67
- function getTileIndex(
68
- tile: SpatialIndexTile,
69
- spatialIndex: SpatialIndex
70
- ): bigint | string {
71
- if (spatialIndex === SpatialIndex.QUADBIN) {
72
- // @ts-expect-error Missing types for quadbin tile indices.
73
- return tile.index.q;
74
- }
75
- return tile.id;
76
- }
77
-
78
56
  function getResolution(
79
57
  tiles: SpatialIndexTile[],
80
58
  spatialIndex: SpatialIndex
81
- ): bigint | number | undefined {
59
+ ): number | undefined {
82
60
  const data = tiles.find((tile) => tile.data?.length)?.data;
83
61
 
84
62
  if (!data) {
@@ -94,6 +72,41 @@ function getResolution(
94
72
  }
95
73
  }
96
74
 
75
+ const bboxWest: BBox = [-180, -90, 0, 90];
76
+ const bboxEast: BBox = [0, -90, 180, 90];
77
+
78
+ function getCellsCoverGeometry(
79
+ geometry: SpatialFilter,
80
+ spatialIndex: SpatialIndex,
81
+ resolution: number
82
+ ) {
83
+ if (spatialIndex === SpatialIndex.QUADBIN) {
84
+ // @ts-expect-error TODO: Probably ought to be stricter about number vs. bigint types in this file.
85
+ return geometryToCells(geometry, resolution);
86
+ }
87
+
88
+ if (spatialIndex === SpatialIndex.H3) {
89
+ // The current H3 polyfill algorithm can't deal with polygon segments of greater than 180 degrees longitude
90
+ // so we clip the geometry to be sure that none of them is greater than 180 degrees
91
+ // https://github.com/uber/h3-js/issues/24#issuecomment-431893796
92
+ return polygonToCells(
93
+ bboxClip(geometry, bboxWest).geometry.coordinates as
94
+ | number[][]
95
+ | number[][][],
96
+ resolution,
97
+ true
98
+ ).concat(
99
+ polygonToCells(
100
+ bboxClip(geometry, bboxEast).geometry.coordinates as
101
+ | number[][]
102
+ | number[][][],
103
+ resolution,
104
+ true
105
+ )
106
+ );
107
+ }
108
+ }
109
+
97
110
  function getSpatialIndex(spatialDataType: SpatialDataType): SpatialIndex {
98
111
  switch (spatialDataType) {
99
112
  case 'h3':
@@ -1,12 +1,12 @@
1
1
  import {aggregationFunctions, aggregate} from './aggregation.js';
2
+ import {OTHERS_CATEGORY_NAME} from '../widget-sources/constants.js';
2
3
  import type {AggregationType} from '../types.js';
3
4
  import type {FeatureData} from '../types-internal.js';
4
-
5
- /** @privateRemarks Source: @carto/react-core */
6
- export type GroupByFeature = {
7
- name: string;
8
- value: number;
9
- }[];
5
+ import type {
6
+ CategoryOrderBy,
7
+ CategoryResponseEntry,
8
+ CategoryResponseRaw,
9
+ } from '../widget-sources/types.js';
10
10
 
11
11
  /** @privateRemarks Source: @carto/react-core */
12
12
  export function groupValuesByColumn({
@@ -15,15 +15,19 @@ export function groupValuesByColumn({
15
15
  joinOperation,
16
16
  keysColumn,
17
17
  operation,
18
+ othersThreshold,
19
+ orderBy = 'frequency_desc',
18
20
  }: {
19
21
  data: FeatureData[];
20
22
  valuesColumns?: string[];
21
23
  joinOperation?: AggregationType;
22
24
  keysColumn: string;
23
25
  operation: AggregationType;
24
- }): GroupByFeature | null {
26
+ othersThreshold?: number;
27
+ orderBy?: CategoryOrderBy;
28
+ }): CategoryResponseRaw | null {
25
29
  if (Array.isArray(data) && data.length === 0) {
26
- return null;
30
+ return {rows: null};
27
31
  }
28
32
  const groups = data.reduce((accumulator, item) => {
29
33
  const group = item[keysColumn];
@@ -48,12 +52,57 @@ export function groupValuesByColumn({
48
52
  const targetOperation =
49
53
  aggregationFunctions[operation as Exclude<AggregationType, 'custom'>];
50
54
 
51
- if (targetOperation) {
52
- return Array.from(groups).map(([name, value]) => ({
55
+ if (!targetOperation) {
56
+ return {rows: []};
57
+ }
58
+
59
+ const allCategories = Array.from(groups)
60
+ .map(([name, value]) => ({
53
61
  name,
54
62
  value: targetOperation(value),
55
- }));
63
+ }))
64
+ .sort(getSorter(orderBy));
65
+
66
+ if (othersThreshold && allCategories.length > othersThreshold) {
67
+ const otherValue = allCategories
68
+ .slice(othersThreshold)
69
+ .flatMap(({name}) => groups.get(name));
70
+ allCategories.push({
71
+ name: OTHERS_CATEGORY_NAME,
72
+ value: targetOperation(otherValue),
73
+ });
74
+ return {
75
+ rows: allCategories,
76
+ metadata: {
77
+ others: targetOperation(otherValue),
78
+ },
79
+ };
56
80
  }
57
81
 
58
- return [];
82
+ return {
83
+ rows: allCategories,
84
+ };
85
+ }
86
+
87
+ export function getSorter(
88
+ orderBy: CategoryOrderBy
89
+ ): (a: CategoryResponseEntry, b: CategoryResponseEntry) => number {
90
+ switch (orderBy) {
91
+ case 'frequency_asc':
92
+ // 'value ASC, name ASC'
93
+ return (a, b) => a.value - b.value || localeCompare(a.name, b.name);
94
+ case 'frequency_desc':
95
+ // 'value DESC, name ASC'
96
+ return (a, b) => b.value - a.value || localeCompare(a.name, b.name);
97
+ case 'alphabetical_asc':
98
+ // 'name ASC, value DESC'
99
+ return (a, b) => localeCompare(a.name, b.name) || b.value - a.value;
100
+ case 'alphabetical_desc':
101
+ // 'name DESC, value DESC'
102
+ return (a, b) => localeCompare(b.name, a.name) || b.value - a.value;
103
+ }
104
+ }
105
+
106
+ function localeCompare(a?: string | null, b?: string | null) {
107
+ return (a ?? 'null').localeCompare(b ?? 'null');
59
108
  }
@@ -1,7 +1,12 @@
1
1
  import type {AggregationType, GroupDateType} from '../types.js';
2
2
  import {getUTCMonday} from '../utils/dateUtils.js';
3
3
  import {aggregate, aggregationFunctions} from './aggregation.js';
4
- import type {GroupByFeature} from './groupBy.js';
4
+
5
+ /** @privateRemarks Source: @carto/react-core */
6
+ export type GroupByFeature = {
7
+ name: string;
8
+ value: number;
9
+ }[];
5
10
 
6
11
  const GROUP_KEY_FN_MAPPING: Record<GroupDateType, (date: Date) => number> = {
7
12
  year: (date: Date) => Date.UTC(date.getUTCFullYear()),
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Name of the category that represents the "Others" category.
3
+ *
4
+ * See `WidgetSource.getCategories` for more information.
5
+ */
6
+ export const OTHERS_CATEGORY_NAME = '_carto_others';
@@ -5,3 +5,4 @@ export * from './widget-remote-source.js';
5
5
  export * from './widget-table-source.js';
6
6
  export * from './widget-tileset-source.js';
7
7
  export * from './types.js';
8
+ export * from './constants.js';
@@ -31,6 +31,12 @@ export interface BaseRequestOptions {
31
31
  filterOwner?: string;
32
32
  }
33
33
 
34
+ export type CategoryOrderBy =
35
+ | 'frequency_asc' // sort by aggregate value ascending, then by name ascending
36
+ | 'frequency_desc' // sort by aggregate value descending, then by name ascending
37
+ | 'alphabetical_asc' // sort by category name ascending, then by value descending
38
+ | 'alphabetical_desc'; // sort by category name descending, then by value descending
39
+
34
40
  /**
35
41
  * Examples:
36
42
  * * population by state
@@ -59,6 +65,15 @@ export interface CategoryRequestOptions extends BaseRequestOptions {
59
65
  operationColumn?: string;
60
66
  /** Local only. */
61
67
  joinOperation?: 'count' | 'avg' | 'min' | 'max' | 'sum';
68
+ /** Calculate `_carto_others` category for all categories after first N (N is threshold). */
69
+ othersThreshold?: number;
70
+ /**
71
+ * Order categories by frequency or alphabetically.
72
+ * @default 'frequency_desc'
73
+ */
74
+ orderBy?: CategoryOrderBy;
75
+ /** Return raw result (CategoryResponseRaw). */
76
+ rawResult?: boolean;
62
77
  }
63
78
 
64
79
  /**
@@ -205,8 +220,17 @@ export type FeaturesResponse = {rows: Record<string, unknown>[]};
205
220
  /** Response from {@link WidgetRemoteSource#getFormula}. */
206
221
  export type FormulaResponse = {value: number | null};
207
222
 
223
+ /** Entry in the category widget response, see {@link WidgetRemoteSource#getCategories}. */
224
+ export type CategoryResponseEntry = {name: string | null; value: number};
208
225
  /** Response from {@link WidgetRemoteSource#getCategories}. */
209
- export type CategoryResponse = {name: string; value: number}[];
226
+ export type CategoryResponse = CategoryResponseEntry[];
227
+
228
+ export type CategoryResponseRaw = {
229
+ rows: CategoryResponseEntry[] | null;
230
+ metadata?: {
231
+ others?: number;
232
+ };
233
+ };
210
234
 
211
235
  /** Response from {@link WidgetRemoteSource#getRange}. */
212
236
  export type RangeResponse = {min: number; max: number} | null;
@@ -23,6 +23,7 @@ import {WidgetSource, type WidgetSourceProps} from './widget-source.js';
23
23
  import type {Filters} from '../types.js';
24
24
  import {AggregationTypes, ApiVersion} from '../constants.js';
25
25
  import {getApplicableFilters} from '../filters.js';
26
+ import {OTHERS_CATEGORY_NAME} from './constants.js';
26
27
 
27
28
  export type WidgetRemoteSourceProps = WidgetSourceProps;
28
29
 
@@ -72,17 +73,23 @@ export abstract class WidgetRemoteSource<
72
73
  filterOwner,
73
74
  spatialFilter,
74
75
  spatialFiltersMode,
76
+ rawResult,
75
77
  ...params
76
78
  } = options;
77
- const {column, operation, operationColumn, operationExp} = params;
79
+ const {
80
+ column,
81
+ operation,
82
+ operationColumn,
83
+ operationExp,
84
+ othersThreshold,
85
+ orderBy,
86
+ } = params;
78
87
 
79
88
  if (operation === AggregationTypes.Custom) {
80
89
  assert(operationExp, 'operationExp is required for custom operation');
81
90
  }
82
91
 
83
- type CategoriesModelResponse = {rows: {name: string; value: number}[]};
84
-
85
- return executeModel({
92
+ const result = await executeModel({
86
93
  model: 'category',
87
94
  source: {
88
95
  ...this.getModelSource(filters, filterOwner),
@@ -94,9 +101,25 @@ export abstract class WidgetRemoteSource<
94
101
  operation,
95
102
  operationExp,
96
103
  operationColumn: operationColumn || column,
104
+ othersThreshold,
105
+ orderBy,
97
106
  },
98
107
  opts: {signal, headers: this.props.headers},
99
- }).then((res: CategoriesModelResponse) => normalizeObjectKeys(res.rows));
108
+ });
109
+
110
+ const normalizedRows = normalizeObjectKeys(result.rows || []);
111
+ if (rawResult) {
112
+ return result as unknown as CategoryResponse;
113
+ }
114
+
115
+ if (!othersThreshold) {
116
+ return normalizedRows;
117
+ }
118
+
119
+ return [
120
+ ...normalizedRows,
121
+ {name: OTHERS_CATEGORY_NAME, value: result?.metadata?.others as number},
122
+ ];
100
123
  }
101
124
 
102
125
  async getFeatures(