@carto/ps-react-ui 4.17.3 → 4.18.0

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.
Files changed (74) hide show
  1. package/dist/category-Ct2BzADB.js +753 -0
  2. package/dist/category-Ct2BzADB.js.map +1 -0
  3. package/dist/change-column-Bhcxmp-r.js +1148 -0
  4. package/dist/change-column-Bhcxmp-r.js.map +1 -0
  5. package/dist/echart-B10dhEaM.js +262 -0
  6. package/dist/echart-B10dhEaM.js.map +1 -0
  7. package/dist/types/widgets-v2/actions/lock-selection/lock-selection.d.ts +15 -5
  8. package/dist/types/widgets-v2/category/components/category-legend.d.ts +18 -2
  9. package/dist/types/widgets-v2/category/components/category-row-stacked.d.ts +6 -1
  10. package/dist/types/widgets-v2/category/style.d.ts +8 -0
  11. package/dist/types/widgets-v2/echart/use-chart-selection.d.ts +10 -4
  12. package/dist/types/widgets-v2/histogram/index.d.ts +1 -1
  13. package/dist/types/widgets-v2/histogram/transforms.d.ts +11 -0
  14. package/dist/types/widgets-v2/histogram/types.d.ts +9 -0
  15. package/dist/types/widgets-v2/scatterplot/index.d.ts +1 -1
  16. package/dist/types/widgets-v2/scatterplot/transforms.d.ts +9 -0
  17. package/dist/types/widgets-v2/table/index.d.ts +1 -0
  18. package/dist/types/widgets-v2/table/transforms.d.ts +13 -0
  19. package/dist/types/widgets-v2/table/transforms.test.d.ts +1 -0
  20. package/dist/types/widgets-v2/timeseries/index.d.ts +1 -0
  21. package/dist/types/widgets-v2/timeseries/transforms.d.ts +10 -0
  22. package/dist/types/widgets-v2/timeseries/transforms.test.d.ts +1 -0
  23. package/dist/widgets-v2/actions.js +1 -1
  24. package/dist/widgets-v2/category.js +1 -1
  25. package/dist/widgets-v2/echart.js +1 -1
  26. package/dist/widgets-v2/echart.js.map +1 -1
  27. package/dist/widgets-v2/histogram.js +181 -158
  28. package/dist/widgets-v2/histogram.js.map +1 -1
  29. package/dist/widgets-v2/pie.js +155 -116
  30. package/dist/widgets-v2/pie.js.map +1 -1
  31. package/dist/widgets-v2/scatterplot.js +91 -85
  32. package/dist/widgets-v2/scatterplot.js.map +1 -1
  33. package/dist/widgets-v2/table.js +55 -45
  34. package/dist/widgets-v2/table.js.map +1 -1
  35. package/dist/widgets-v2/timeseries.js +96 -85
  36. package/dist/widgets-v2/timeseries.js.map +1 -1
  37. package/dist/widgets-v2.js +4 -4
  38. package/package.json +1 -1
  39. package/src/widgets-v2/actions/lock-selection/lock-selection.test.tsx +62 -0
  40. package/src/widgets-v2/actions/lock-selection/lock-selection.tsx +45 -13
  41. package/src/widgets-v2/category/category-ui.test.tsx +55 -0
  42. package/src/widgets-v2/category/category-ui.tsx +65 -6
  43. package/src/widgets-v2/category/components/category-legend.test.tsx +53 -2
  44. package/src/widgets-v2/category/components/category-legend.tsx +55 -12
  45. package/src/widgets-v2/category/components/category-row-stacked.test.tsx +7 -3
  46. package/src/widgets-v2/category/components/category-row-stacked.tsx +8 -3
  47. package/src/widgets-v2/category/style.ts +10 -2
  48. package/src/widgets-v2/echart/echart-ui.test.tsx +25 -0
  49. package/src/widgets-v2/echart/echart-ui.tsx +20 -0
  50. package/src/widgets-v2/echart/use-chart-selection.test.tsx +3 -1
  51. package/src/widgets-v2/echart/use-chart-selection.ts +10 -4
  52. package/src/widgets-v2/histogram/index.ts +4 -1
  53. package/src/widgets-v2/histogram/options.test.ts +86 -0
  54. package/src/widgets-v2/histogram/options.ts +50 -0
  55. package/src/widgets-v2/histogram/transforms.test.ts +36 -1
  56. package/src/widgets-v2/histogram/transforms.ts +23 -0
  57. package/src/widgets-v2/histogram/types.ts +9 -0
  58. package/src/widgets-v2/pie/options.test.ts +262 -0
  59. package/src/widgets-v2/pie/options.ts +80 -5
  60. package/src/widgets-v2/scatterplot/index.ts +4 -1
  61. package/src/widgets-v2/scatterplot/transforms.test.ts +38 -1
  62. package/src/widgets-v2/scatterplot/transforms.ts +23 -0
  63. package/src/widgets-v2/table/index.ts +1 -0
  64. package/src/widgets-v2/table/transforms.test.ts +40 -0
  65. package/src/widgets-v2/table/transforms.ts +26 -0
  66. package/src/widgets-v2/timeseries/index.ts +1 -0
  67. package/src/widgets-v2/timeseries/transforms.test.ts +49 -0
  68. package/src/widgets-v2/timeseries/transforms.ts +31 -0
  69. package/dist/category-CGS_eHr4.js +0 -719
  70. package/dist/category-CGS_eHr4.js.map +0 -1
  71. package/dist/change-column-CiVAjOUB.js +0 -1143
  72. package/dist/change-column-CiVAjOUB.js.map +0 -1
  73. package/dist/echart-Bdvbfx9s.js +0 -250
  74. package/dist/echart-Bdvbfx9s.js.map +0 -1
@@ -55,6 +55,13 @@ export function pieOptions({
55
55
  left: 'center',
56
56
  type: 'scroll',
57
57
  itemGap: 16,
58
+ // The legend is presentational only here — the pie has its own
59
+ // click-to-select affordance on the slices themselves, so legend
60
+ // clicks toggling series visibility would be a second, redundant/
61
+ // conflicting interaction model. Hover-highlight is disabled per
62
+ // series via `legendHoverLink` (ECharts has no legend-level knob
63
+ // for it).
64
+ selectedMode: false,
58
65
  },
59
66
  tooltip: {
60
67
  // Pie uses item-trigger (no axis); the rest of the styling mirrors
@@ -89,6 +96,7 @@ export function pieOptions({
89
96
  colorBy: 'data',
90
97
  avoidLabelOverlap: true,
91
98
  selectedOffset: 0,
99
+ legendHoverLink: false,
92
100
  emphasis: { disabled: true },
93
101
  itemStyle: {
94
102
  borderColor: theme.palette.background.paper,
@@ -129,10 +137,14 @@ export function createPieOptionFactory(
129
137
  const { theme, formatter, labelFormatter, optionsOverride, series } = options
130
138
  const radius = options.radius ?? DEFAULT_RADIUS
131
139
  const selection = options.selection
140
+ // An explicit `[]` (deselected down to zero via chart clicks) is treated
141
+ // the same as no selection: the pie has no bar/histogram-style "nothing
142
+ // selected" affordance, so dimming every slice would just look broken —
143
+ // it renders fully active, same as the no-interaction state.
132
144
  const selectionSet =
133
- selection && selection.length > 0
134
- ? new Set<string | number>(selection)
135
- : null
145
+ selection === undefined || selection === null || selection.length === 0
146
+ ? null
147
+ : new Set<string | number>(selection)
136
148
  return (option, data, ctx) => {
137
149
  if (option == null) {
138
150
  const structural = pieOptions({ theme, formatter, labelFormatter })
@@ -217,6 +229,13 @@ function buildSingleSeriesPieFusion(
217
229
  // not conditionally — same anti-stale-callback rationale as bar /
218
230
  // histogram. The slice border + width come from the structural
219
231
  // template's `itemStyle` and survive via the spread below.
232
+ //
233
+ // `selectionSet` is the *included* set, same direct semantics as
234
+ // bar/histogram/timeseries (no ADR-014 exclusion inversion). Dim a slice
235
+ // when its name is NOT in the set. `selectionSet` is never a non-null
236
+ // empty Set — the collapse above (`selection.length === 0 → null`) rules
237
+ // that out — so there's no "dim everything" case here, unlike a widget
238
+ // that gives `[]` its own "selected down to nothing" meaning.
220
239
  const colorFn = (params: CallbackDataParams): string => {
221
240
  const base = resolvePaletteColor(params)
222
241
  if (!selectionSet) return base
@@ -227,12 +246,33 @@ function buildSingleSeriesPieFusion(
227
246
  : echarts.color.modifyAlpha(base, 0.15)
228
247
  }
229
248
 
249
+ // Which slice renders the centre value+name: the greatest value among
250
+ // the *included* slices (all of them when there's no active
251
+ // selection), computed locally — no query round-trip. Only that one
252
+ // slice's formatter call returns text; every other slice returns ''
253
+ // so exactly one label ever renders at the shared centre anchor. Only
254
+ // `typeof value === 'number'` participates — if every included slice's
255
+ // value is non-numeric, `centerMaxName` stays `undefined` and no slice
256
+ // matches it in `labelTextFormatter` below, so the chart renders with no
257
+ // centre label at all (graceful no-op, not a crash).
258
+ const centerData = seriesArr[0] ?? []
259
+ let centerMaxName: string | number | undefined
260
+ let centerMaxValue = -Infinity
261
+ for (const d of centerData) {
262
+ if (selectionSet && !selectionSet.has(d.name)) continue
263
+ if (typeof d.value === 'number' && d.value > centerMaxValue) {
264
+ centerMaxValue = d.value
265
+ centerMaxName = d.name
266
+ }
267
+ }
268
+
230
269
  // Center label formatter — lives in the merger because it reads
231
270
  // reactive `formatter` / `labelFormatter` from ctx. The rich tags
232
271
  // `{c|…}` and `{b|…}` reference the rich styles baked into the
233
272
  // structural label template.
234
273
  const labelTextFormatter = (params: CallbackDataParams): string => {
235
274
  const { name } = params
275
+ if (name == null || name !== centerMaxName) return ''
236
276
  const encodeIndex = params.encode?.value?.[0]
237
277
  if (encodeIndex === undefined) return ''
238
278
  const value = (Object.values(params.data ?? {}) as unknown[]).at(
@@ -269,6 +309,7 @@ function buildSingleSeriesPieFusion(
269
309
  return {
270
310
  ...templateObj,
271
311
  type: 'pie' as const,
312
+ legendHoverLink: false,
272
313
  datasetIndex: i,
273
314
  name: series?.[i]?.name ?? `Series ${i + 1}`,
274
315
  radius: [...radius],
@@ -291,7 +332,27 @@ function buildSingleSeriesPieFusion(
291
332
  }),
292
333
  // Legend always shows for pie (mirrors v1) — slice names drive the
293
334
  // entries, so even a single donut benefits from a category list.
294
- legend: { ...baseLegend, show: true },
335
+ //
336
+ // Exclusion is conveyed purely via `colorFn`/`labelTextFormatter`
337
+ // dimming above, never via the legend's `selected` map: for a pie
338
+ // series, ECharts treats `legend.selected[name] = false` as "remove
339
+ // this data point," which recomputes every remaining slice's angle —
340
+ // the excluded slice disappears instead of rendering dimmed. Forcing
341
+ // every name to `true` on every render also overrides ECharts' own
342
+ // internal toggle state from a native legend click (which flips a
343
+ // name to `false` and hides it before our `legendselectchanged`
344
+ // handler gets a chance to react), so a legend click never leaves a
345
+ // slice structurally removed — only the color/label dimming reflects
346
+ // exclusion, and it stays in sync with the slice itself since both
347
+ // read `selectionSet` on the same render.
348
+ legend: {
349
+ ...baseLegend,
350
+ show: true,
351
+ selectedMode: false,
352
+ selected: Object.fromEntries(
353
+ (seriesArr[0] ?? []).map((d) => [String(d.name), true]),
354
+ ),
355
+ },
295
356
  tooltip: {
296
357
  ...baseTooltip,
297
358
  formatter: buildPieTooltipFormatter(formatter, labelFormatter),
@@ -372,6 +433,7 @@ function buildMultiSeriesBarFusion(
372
433
  type: 'bar' as const,
373
434
  name: series?.[i]?.name ?? `Series ${i + 1}`,
374
435
  barMaxWidth: 100,
436
+ legendHoverLink: false,
375
437
  emphasis: { focus: 'series' },
376
438
  ...buildSeriesLabelConfig(formatter, 'x'),
377
439
  itemStyle: { color: barColorFn },
@@ -422,7 +484,20 @@ function buildMultiSeriesBarFusion(
422
484
  }),
423
485
  },
424
486
  },
425
- legend: { ...baseLegend, show: true },
487
+ // Mirrors the donut branch's `selected` reset above — `selectedMode:
488
+ // false` blocks the native legend click that would otherwise undo an
489
+ // ECharts-internal toggle, so without forcing every entry back to
490
+ // `true` here a prior native toggle (from before `selectedMode` took
491
+ // effect, or a stale `keepAlive` instance) would stay hidden forever
492
+ // with no way to restore it.
493
+ legend: {
494
+ ...baseLegend,
495
+ show: true,
496
+ selectedMode: false,
497
+ selected: Object.fromEntries(
498
+ seriesArr.map((_, i) => [series?.[i]?.name ?? `Series ${i + 1}`, true]),
499
+ ),
500
+ },
426
501
  tooltip: {
427
502
  ...baseTooltip,
428
503
  trigger: 'axis',
@@ -1,7 +1,10 @@
1
1
  export { scatterplotOptions, createScatterplotOptionFactory } from './options'
2
2
  export { ScatterplotSkeleton, type ScatterplotSkeletonProps } from './skeleton'
3
3
  export { createScatterplotDownloadConfig } from './download'
4
- export { toRelativeScatterplotData } from './transforms'
4
+ export {
5
+ toRelativeScatterplotData,
6
+ filterScatterplotByLockedPoints,
7
+ } from './transforms'
5
8
  export type {
6
9
  ScatterplotDatum,
7
10
  ScatterplotWidgetData,
@@ -1,5 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { toRelativeScatterplotData } from './transforms'
2
+ import {
3
+ toRelativeScatterplotData,
4
+ filterScatterplotByLockedPoints,
5
+ } from './transforms'
3
6
 
4
7
  describe('toRelativeScatterplotData', () => {
5
8
  it('rewrites each tuple y as its share of the series total in 0-100, x stays raw', () => {
@@ -95,3 +98,37 @@ describe('toRelativeScatterplotData', () => {
95
98
  ])
96
99
  })
97
100
  })
101
+
102
+ describe('filterScatterplotByLockedPoints', () => {
103
+ const DATA = [
104
+ [
105
+ [1, 10],
106
+ [2, 20],
107
+ [3, 30],
108
+ ],
109
+ [
110
+ [4, 40],
111
+ [5, 50],
112
+ ],
113
+ ]
114
+
115
+ it('returns input unchanged when locked set is empty', () => {
116
+ expect(filterScatterplotByLockedPoints(DATA, [])).toBe(DATA)
117
+ })
118
+
119
+ it('keeps only points whose `${seriesIndex}:${dataIndex}` key is locked', () => {
120
+ expect(
121
+ filterScatterplotByLockedPoints(DATA, ['0:0', '0:2', '1:1']),
122
+ ).toEqual([
123
+ [
124
+ [1, 10],
125
+ [3, 30],
126
+ ],
127
+ [[5, 50]],
128
+ ])
129
+ })
130
+
131
+ it('returns input unchanged when input is not array-shaped', () => {
132
+ expect(filterScatterplotByLockedPoints('nope', ['0:0'])).toBe('nope')
133
+ })
134
+ })
@@ -36,3 +36,26 @@ function isXyTupleArray(v: unknown): v is [number, number][] {
36
36
  Number.isFinite(item[1]),
37
37
  )
38
38
  }
39
+
40
+ /**
41
+ * `LockSelection` filter for Scatterplot. Data is `[number, number][][]` —
42
+ * points have no `name`, so selection (and locking) is keyed on a synthetic
43
+ * `${seriesIndex}:${dataIndex}` string (same format `useChartSelection`'s
44
+ * `keyFromClick`/`keysFromBrush` produce for this widget). Unlike Histogram,
45
+ * a point's position carries no external meaning (no `ticks` to desync from),
46
+ * so non-locked points are removed outright rather than zeroed.
47
+ */
48
+ export function filterScatterplotByLockedPoints(
49
+ input: unknown,
50
+ lockedItems: readonly (string | number)[],
51
+ ): unknown {
52
+ if (lockedItems.length === 0) return input
53
+ if (!Array.isArray(input)) return input
54
+ const locked = new Set<string>(lockedItems.map(String))
55
+ return input.map((series: unknown, seriesIndex: number): unknown => {
56
+ if (!isXyTupleArray(series)) return series
57
+ return series.filter((_, dataIndex) =>
58
+ locked.has(`${seriesIndex}:${dataIndex}`),
59
+ )
60
+ })
61
+ }
@@ -2,6 +2,7 @@ export { Table, type TableProps } from './table'
2
2
  export { TableUI, type TableUIProps } from './table-ui'
3
3
  export { TableSkeleton, type TableSkeletonProps } from './skeleton'
4
4
  export { createTableDownloadConfig } from './download'
5
+ export { filterTableByLockedRows } from './transforms'
5
6
  export {
6
7
  compareValues,
7
8
  sortRows,
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { filterTableByLockedRows } from './transforms'
3
+
4
+ describe('filterTableByLockedRows', () => {
5
+ const ROWS = [
6
+ { id: 'a', label: 'Alpha' },
7
+ { id: 'b', label: 'Beta' },
8
+ { id: 'c', label: 'Gamma' },
9
+ ]
10
+
11
+ it('returns input unchanged when locked set is empty', () => {
12
+ expect(filterTableByLockedRows(ROWS, [])).toBe(ROWS)
13
+ })
14
+
15
+ it('keeps only rows whose default `id` column is locked', () => {
16
+ expect(filterTableByLockedRows(ROWS, ['a', 'c'])).toEqual([
17
+ { id: 'a', label: 'Alpha' },
18
+ { id: 'c', label: 'Gamma' },
19
+ ])
20
+ })
21
+
22
+ it('uses a custom keyColumn when supplied', () => {
23
+ const rows = [
24
+ { sku: 'X1', name: 'Widget' },
25
+ { sku: 'X2', name: 'Gadget' },
26
+ ]
27
+ expect(filterTableByLockedRows(rows, ['X2'], 'sku')).toEqual([
28
+ { sku: 'X2', name: 'Gadget' },
29
+ ])
30
+ })
31
+
32
+ it('falls back to row index when the identity column is nullish', () => {
33
+ const rows = [{ label: 'first' }, { label: 'second' }]
34
+ expect(filterTableByLockedRows(rows, [1])).toEqual([{ label: 'second' }])
35
+ })
36
+
37
+ it('returns input unchanged when input is not array-shaped', () => {
38
+ expect(filterTableByLockedRows('nope', ['a'])).toBe('nope')
39
+ })
40
+ })
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `LockSelection` filter for Table. Data is a FLAT `TableRow[]` (unlike every
3
+ * other widget's `X[][]` series shape) and row identity comes from
4
+ * `row[keyColumn]` (default `'id'`), not `name` — so `filterByLockedItems`
5
+ * doesn't apply here (its outer `.map` treats each row as a "series", and a
6
+ * plain row object isn't an array, so it would silently pass everything
7
+ * through unfiltered). Falls back to the row's index when the identity
8
+ * column is nullish, mirroring `TableUI`'s own `resolveRowId`.
9
+ *
10
+ * `keyColumn` isn't part of `LockSelection`'s generic `filterFn` signature —
11
+ * close over it: `filterFn={(data, locked) => filterTableByLockedRows(data, locked, keyColumn)}`.
12
+ */
13
+ export function filterTableByLockedRows(
14
+ input: unknown,
15
+ lockedItems: readonly (string | number)[],
16
+ keyColumn = 'id',
17
+ ): unknown {
18
+ if (lockedItems.length === 0) return input
19
+ if (!Array.isArray(input)) return input
20
+ const locked = new Set<string | number>(lockedItems)
21
+ return input.filter((row: unknown, index: number): boolean => {
22
+ if (row == null || typeof row !== 'object') return false
23
+ const id = (row as Record<string, unknown>)[keyColumn] ?? index
24
+ return (typeof id === 'string' || typeof id === 'number') && locked.has(id)
25
+ })
26
+ }
@@ -1,6 +1,7 @@
1
1
  export { timeseriesOptions, createTimeseriesOptionFactory } from './options'
2
2
  export { TimeseriesSkeleton } from './skeleton'
3
3
  export { createTimeseriesDownloadConfig } from './download'
4
+ export { filterTimeseriesByLockedItems } from './transforms'
4
5
  export type {
5
6
  TimeseriesDatum,
6
7
  TimeseriesWidgetData,
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { filterTimeseriesByLockedItems } from './transforms'
3
+
4
+ describe('filterTimeseriesByLockedItems', () => {
5
+ it('returns input unchanged when locked set is empty', () => {
6
+ const data = [[{ name: 1000, value: 10 }]]
7
+ expect(filterTimeseriesByLockedItems(data, [])).toBe(data)
8
+ })
9
+
10
+ it('keeps only items whose (numeric) name is in the locked set', () => {
11
+ const out = filterTimeseriesByLockedItems(
12
+ [
13
+ [
14
+ { name: 1000, value: 10 },
15
+ { name: 2000, value: 20 },
16
+ ],
17
+ ],
18
+ [2000],
19
+ )
20
+ expect(out).toEqual([[{ name: 2000, value: 20 }]])
21
+ })
22
+
23
+ it('normalizes Date names to ms-since-epoch before comparing', () => {
24
+ const d1 = new Date('2024-01-01T00:00:00.000Z')
25
+ const d2 = new Date('2024-01-02T00:00:00.000Z')
26
+ const out = filterTimeseriesByLockedItems(
27
+ [
28
+ [
29
+ { name: d1, value: 10 },
30
+ { name: d2, value: 20 },
31
+ ],
32
+ ],
33
+ [d2.getTime()],
34
+ )
35
+ expect(out).toEqual([[{ name: d2, value: 20 }]])
36
+ })
37
+
38
+ it('handles string names', () => {
39
+ const out = filterTimeseriesByLockedItems(
40
+ [[{ name: '2024-01-01', value: 10 }]],
41
+ ['2024-01-01'],
42
+ )
43
+ expect(out).toEqual([[{ name: '2024-01-01', value: 10 }]])
44
+ })
45
+
46
+ it('returns input unchanged when input is not array-shaped', () => {
47
+ expect(filterTimeseriesByLockedItems('nope', [1])).toBe('nope')
48
+ })
49
+ })
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `LockSelection` filter for Timeseries. Data is `{name, value}[][]` like
3
+ * Bar/Pie/Category, but `name` may arrive as `Date | number | string` (the
4
+ * time axis stores a `Date`). `lockedItems` is snapshotted from `selection`,
5
+ * whose keys are already normalized to `Date → ms` (see `options.ts`'s
6
+ * `normalizeName`) — so a raw `Date` name must be normalized the same way
7
+ * before the membership check, or every point would compare a `Date` object
8
+ * against a `number` and silently fail to match, filtering everything out.
9
+ */
10
+ export function filterTimeseriesByLockedItems(
11
+ input: unknown,
12
+ lockedItems: readonly (string | number)[],
13
+ ): unknown {
14
+ if (lockedItems.length === 0) return input
15
+ if (!Array.isArray(input)) return input
16
+ const locked = new Set<string | number>(lockedItems)
17
+ return input.map((series: unknown): unknown => {
18
+ if (!Array.isArray(series)) return series
19
+ return series.filter((item: unknown) => {
20
+ if (item == null || typeof item !== 'object' || !('name' in item)) {
21
+ return false
22
+ }
23
+ const raw = (item as { name: unknown }).name
24
+ const name = raw instanceof Date ? raw.getTime() : raw
25
+ return (
26
+ (typeof name === 'string' || typeof name === 'number') &&
27
+ locked.has(name)
28
+ )
29
+ })
30
+ })
31
+ }