@carto/ps-react-ui 4.8.0 → 4.9.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.
Files changed (129) hide show
  1. package/dist/{change-column-Cidl_M-4.js → change-column-B4IT0rh6.js} +2 -2
  2. package/dist/{change-column-Cidl_M-4.js.map → change-column-B4IT0rh6.js.map} +1 -1
  3. package/dist/components.js +4 -3
  4. package/dist/components.js.map +1 -1
  5. package/dist/{data-zoom-layout-BH0LPwSy.js → data-zoom-layout-0QSptXG_.js} +2 -2
  6. package/dist/{data-zoom-layout-BH0LPwSy.js.map → data-zoom-layout-0QSptXG_.js.map} +1 -1
  7. package/dist/{download-config-DNLkypdN.js → download-config-CzmjOT2T.js} +2 -2
  8. package/dist/{download-config-DNLkypdN.js.map → download-config-CzmjOT2T.js.map} +1 -1
  9. package/dist/{lasso-tool-BYbxrJ-7.js → lasso-tool-CDFj4zKY.js} +2 -1
  10. package/dist/lasso-tool-CDFj4zKY.js.map +1 -0
  11. package/dist/range-l4fNHLEg.js +213 -0
  12. package/dist/range-l4fNHLEg.js.map +1 -0
  13. package/dist/resolve-theme-color-BdojIw0K.js +47 -0
  14. package/dist/resolve-theme-color-BdojIw0K.js.map +1 -0
  15. package/dist/{spread-CTuIXZSM.js → spread-Y9R1f5dm.js} +2 -2
  16. package/dist/{spread-CTuIXZSM.js.map → spread-Y9R1f5dm.js.map} +1 -1
  17. package/dist/table-CQCAnDLb.js +388 -0
  18. package/dist/table-CQCAnDLb.js.map +1 -0
  19. package/dist/types/components/lasso-tool/styles.d.ts +1 -0
  20. package/dist/types/components/measurement-tools/styles.d.ts +1 -0
  21. package/dist/types/widgets/actions/brush-toggle/style.d.ts +1 -1
  22. package/dist/types/widgets/actions/shared/styles.d.ts +1 -1
  23. package/dist/types/widgets/actions/zoom-toggle/style.d.ts +1 -1
  24. package/dist/types/widgets/echart/types.d.ts +1 -1
  25. package/dist/types/widgets/toolbar-actions/styles.d.ts +1 -1
  26. package/dist/types/widgets-v2/actions/brush-toggle/style.d.ts +1 -1
  27. package/dist/types/widgets-v2/actions/change-column/style.d.ts +1 -1
  28. package/dist/types/widgets-v2/actions/fullscreen/style.d.ts +1 -1
  29. package/dist/types/widgets-v2/actions/lock-selection/style.d.ts +1 -1
  30. package/dist/types/widgets-v2/actions/relative-data/style.d.ts +1 -1
  31. package/dist/types/widgets-v2/actions/searcher/style.d.ts +1 -1
  32. package/dist/types/widgets-v2/actions/stack-toggle/style.d.ts +1 -1
  33. package/dist/types/widgets-v2/actions/zoom-toggle/style.d.ts +1 -1
  34. package/dist/types/widgets-v2/bar/types.d.ts +8 -3
  35. package/dist/types/widgets-v2/category/types.d.ts +8 -4
  36. package/dist/types/widgets-v2/formula/types.d.ts +10 -7
  37. package/dist/types/widgets-v2/histogram/types.d.ts +7 -3
  38. package/dist/types/widgets-v2/index.d.ts +1 -0
  39. package/dist/types/widgets-v2/pie/types.d.ts +10 -3
  40. package/dist/types/widgets-v2/range/range-ui.d.ts +12 -4
  41. package/dist/types/widgets-v2/range/range.d.ts +13 -8
  42. package/dist/types/widgets-v2/scatterplot/types.d.ts +7 -3
  43. package/dist/types/widgets-v2/table/style.d.ts +0 -4
  44. package/dist/types/widgets-v2/table/table-ui.d.ts +7 -1
  45. package/dist/types/widgets-v2/table/table.d.ts +1 -1
  46. package/dist/types/widgets-v2/table/types.d.ts +13 -2
  47. package/dist/types/widgets-v2/timeseries/types.d.ts +7 -3
  48. package/dist/types/widgets-v2/types.d.ts +25 -0
  49. package/dist/types/widgets-v2/utils/index.d.ts +1 -0
  50. package/dist/types/widgets-v2/utils/resolve-theme-color.d.ts +18 -0
  51. package/dist/types/widgets-v2/utils/resolve-theme-color.test.d.ts +1 -0
  52. package/dist/types/widgets-v2/wrapper/style.d.ts +1 -2
  53. package/dist/types/widgets-v2/wrapper/widget-wrapper.d.ts +6 -1
  54. package/dist/widgets/actions.js +1 -1
  55. package/dist/widgets/bar.js +1 -1
  56. package/dist/widgets/category.js +1 -1
  57. package/dist/widgets/formula.js +1 -1
  58. package/dist/widgets/histogram.js +1 -1
  59. package/dist/widgets/markdown.js +1 -1
  60. package/dist/widgets/pie.js +1 -1
  61. package/dist/widgets/scatterplot.js +1 -1
  62. package/dist/widgets/spread.js +1 -1
  63. package/dist/widgets/table.js +1 -1
  64. package/dist/widgets/timeseries.js +1 -1
  65. package/dist/widgets/utils.js +1 -1
  66. package/dist/widgets/wrapper.js +1 -1
  67. package/dist/widgets-v2/actions.js +1 -1
  68. package/dist/widgets-v2/bar.js +59 -56
  69. package/dist/widgets-v2/bar.js.map +1 -1
  70. package/dist/widgets-v2/category.js +1 -1
  71. package/dist/widgets-v2/formula.js +1 -1
  72. package/dist/widgets-v2/histogram.js +66 -63
  73. package/dist/widgets-v2/histogram.js.map +1 -1
  74. package/dist/widgets-v2/markdown.js +1 -1
  75. package/dist/widgets-v2/pie.js +101 -95
  76. package/dist/widgets-v2/pie.js.map +1 -1
  77. package/dist/widgets-v2/range.js +1 -1
  78. package/dist/widgets-v2/scatterplot.js +108 -102
  79. package/dist/widgets-v2/scatterplot.js.map +1 -1
  80. package/dist/widgets-v2/spread.js +2 -2
  81. package/dist/widgets-v2/table.js +3 -3
  82. package/dist/widgets-v2/timeseries.js +86 -80
  83. package/dist/widgets-v2/timeseries.js.map +1 -1
  84. package/dist/widgets-v2/utils.js +4 -3
  85. package/dist/widgets-v2.js +229 -229
  86. package/dist/widgets-v2.js.map +1 -1
  87. package/package.json +5 -3
  88. package/src/components/lasso-tool/styles.ts +1 -0
  89. package/src/components/measurement-tools/styles.ts +1 -0
  90. package/src/widgets/echart/types.ts +1 -1
  91. package/src/widgets-v2/bar/options.test.ts +19 -2
  92. package/src/widgets-v2/bar/options.ts +9 -3
  93. package/src/widgets-v2/bar/types.ts +8 -3
  94. package/src/widgets-v2/category/types.ts +9 -4
  95. package/src/widgets-v2/formula/types.ts +11 -7
  96. package/src/widgets-v2/histogram/options.test.ts +16 -2
  97. package/src/widgets-v2/histogram/options.ts +5 -4
  98. package/src/widgets-v2/histogram/types.ts +7 -3
  99. package/src/widgets-v2/index.ts +3 -0
  100. package/src/widgets-v2/pie/options.test.ts +20 -4
  101. package/src/widgets-v2/pie/options.ts +21 -17
  102. package/src/widgets-v2/pie/types.ts +10 -3
  103. package/src/widgets-v2/range/range-ui.test.tsx +8 -2
  104. package/src/widgets-v2/range/range-ui.tsx +81 -14
  105. package/src/widgets-v2/range/range.tsx +14 -8
  106. package/src/widgets-v2/scatterplot/options.test.ts +15 -3
  107. package/src/widgets-v2/scatterplot/options.ts +15 -11
  108. package/src/widgets-v2/scatterplot/types.ts +7 -3
  109. package/src/widgets-v2/table/style.ts +2 -5
  110. package/src/widgets-v2/table/table-ui.tsx +40 -7
  111. package/src/widgets-v2/table/table.tsx +6 -1
  112. package/src/widgets-v2/table/types.ts +13 -2
  113. package/src/widgets-v2/timeseries/options.test.ts +17 -2
  114. package/src/widgets-v2/timeseries/options.ts +10 -3
  115. package/src/widgets-v2/timeseries/types.ts +7 -3
  116. package/src/widgets-v2/types.ts +25 -0
  117. package/src/widgets-v2/utils/index.ts +1 -0
  118. package/src/widgets-v2/utils/resolve-theme-color.test.ts +43 -0
  119. package/src/widgets-v2/utils/resolve-theme-color.ts +34 -0
  120. package/src/widgets-v2/wrapper/style.ts +1 -2
  121. package/src/widgets-v2/wrapper/widget-wrapper.test.tsx +30 -0
  122. package/src/widgets-v2/wrapper/widget-wrapper.tsx +11 -1
  123. package/dist/lasso-tool-BYbxrJ-7.js.map +0 -1
  124. package/dist/merge-options-DCkkHZIf.js +0 -34
  125. package/dist/merge-options-DCkkHZIf.js.map +0 -1
  126. package/dist/range-DsqTjSpg.js +0 -186
  127. package/dist/range-DsqTjSpg.js.map +0 -1
  128. package/dist/table-HIpXuq4G.js +0 -390
  129. package/dist/table-HIpXuq4G.js.map +0 -1
@@ -1,5 +1,6 @@
1
1
  import type { Theme } from '@mui/material'
2
2
  import type { EChartsOption } from 'echarts'
3
+ import type { WidgetSeries } from '../types'
3
4
 
4
5
  /** A single category entry in a bar series. */
5
6
  export interface BarDatum {
@@ -21,14 +22,18 @@ export interface BarOptionsInput {
21
22
  * Combined inputs for the option factory creator. Carries everything the
22
23
  * widget needs across BOTH phases — the structural-build (`theme`,
23
24
  * `formatter`, `labelFormatter`, `optionsOverride`) AND the data merge
24
- * (`seriesNames`, `selection`).
25
+ * (`series`, `selection`).
25
26
  */
26
27
  export interface BarOptionFactoryInput {
27
28
  theme: Theme
28
29
  formatter?: (value: number) => string
29
30
  labelFormatter?: (value: string | number) => string | number
30
- /** Series names — drives the legend and `series[i].name`. */
31
- seriesNames?: readonly string[]
31
+ /**
32
+ * Per-series metadata — drives the legend, `series[i].name`, and
33
+ * (when `color` is set) per-series `itemStyle.color`. Paired with the
34
+ * data series by index.
35
+ */
36
+ series?: readonly WidgetSeries[]
32
37
  /**
33
38
  * When set, every datum whose `name` is **not** in this list renders dimmed
34
39
  * (`itemStyle.opacity: 0.15`, matching `outOfBrush` styling). `null`/empty
@@ -1,3 +1,5 @@
1
+ import type { WidgetSeries } from '../types'
2
+
1
3
  /** A single row in a Category widget. `name` is the category key. */
2
4
  export interface CategoryDataItem {
3
5
  name: string | number
@@ -29,10 +31,13 @@ export type CategoryKey = string | number
29
31
  * gracefully (extra entries ignored; missing entries get palette
30
32
  * defaults and are absent from the legend).
31
33
  */
32
- export interface CategorySeriesConfig {
33
- name: string
34
- color?: string
35
- }
34
+ /**
35
+ * Per-series metadata. Type alias of the cross-widget
36
+ * {@link WidgetSeries} shape (`{ name, color? }`) so Category, Formula,
37
+ * Spread, and the echart-based widgets all consume the same input
38
+ * shape. Kept as a named export for backwards compatibility.
39
+ */
40
+ export type CategorySeriesConfig = WidgetSeries
36
41
 
37
42
  /**
38
43
  * Labels for the "Other" overflow row (rendered when `data.length`
@@ -1,10 +1,14 @@
1
- /** Series metadata rendered as a coloured avatar at the start of a row. */
2
- export interface FormulaSeries {
3
- /** Used for the avatar's first-letter glyph and as accessible label. */
4
- name: string
5
- /** Avatar background colour (any MUI palette path or CSS colour). */
6
- color?: string
7
- }
1
+ import type { WidgetSeries } from '../types'
2
+
3
+ /**
4
+ * Series metadata rendered as a coloured avatar at the start of a row.
5
+ *
6
+ * Type alias of the cross-widget {@link WidgetSeries} shape so the same
7
+ * `{ name, color? }` object can drive Formula avatars, Spread avatars,
8
+ * Category legends, and echart-widget legends with no per-widget shape
9
+ * gymnastics. Kept as a named export for backwards compatibility.
10
+ */
11
+ export type FormulaSeries = WidgetSeries
8
12
 
9
13
  export type DeltaSeverity = 'positive' | 'negative' | 'neutral'
10
14
 
@@ -213,11 +213,11 @@ describe('createHistogramOptionFactory (data → dataset merger)', () => {
213
213
  expect(out.yAxis.max).toBe(50)
214
214
  })
215
215
 
216
- it('uses seriesNames for series[i].name when provided', () => {
216
+ it('uses series[i].name for series[i].name when provided', () => {
217
217
  const merge = createHistogramOptionFactory({
218
218
  theme,
219
219
  ticks: [0, 10, 20],
220
- seriesNames: ['2024', '2025'],
220
+ series: [{ name: '2024' }, { name: '2025' }],
221
221
  })
222
222
  const out = merge({}, [
223
223
  [1, 2],
@@ -227,6 +227,20 @@ describe('createHistogramOptionFactory (data → dataset merger)', () => {
227
227
  expect(out.series[1]?.name).toBe('2025')
228
228
  })
229
229
 
230
+ it('applies series[i].color as a per-series itemStyle override', () => {
231
+ const merge = createHistogramOptionFactory({
232
+ theme,
233
+ ticks: [0, 10, 20],
234
+ series: [{ name: '2024', color: '#ff0000' }, { name: '2025' }],
235
+ })
236
+ const out = merge({}, [
237
+ [1, 2],
238
+ [3, 4],
239
+ ]) as { series: { color?: string }[] }
240
+ expect(out.series[0]?.color).toBe('#ff0000')
241
+ expect(out.series[1]?.color).toBeUndefined()
242
+ })
243
+
230
244
  it('applies labelFormatter to bin labels', () => {
231
245
  const merge = createHistogramOptionFactory({
232
246
  theme,
@@ -10,7 +10,7 @@ import {
10
10
  } from '../../widgets/utils/chart-config'
11
11
  import { ZOOM_LAYOUT } from '../actions/zoom-toggle'
12
12
  import type { OptionFactory } from '../echart'
13
- import { mergeOptions } from '../utils'
13
+ import { mergeOptions, resolveThemeColor } from '../utils'
14
14
  import { positionDataZoomForLegend } from '../utils/data-zoom-layout'
15
15
  import type {
16
16
  HistogramEChartsOption,
@@ -152,8 +152,7 @@ export function histogramOptions({
152
152
  export function createHistogramOptionFactory(
153
153
  options: HistogramOptionFactoryInput,
154
154
  ): OptionFactory {
155
- const { theme, formatter, ticks, seriesNames, labelFormatter, selection } =
156
- options
155
+ const { theme, formatter, ticks, series, labelFormatter, selection } = options
157
156
  const optionsOverride = options.optionsOverride
158
157
  const selectionSet =
159
158
  selection && selection.length > 0 ? new Set<number>(selection) : null
@@ -253,15 +252,17 @@ export function createHistogramOptionFactory(
253
252
  const template =
254
253
  (seriesTemplates[i] as object | undefined) ??
255
254
  (broadcastTemplate as object)
255
+ const overrideColor = resolveThemeColor(theme, series?.[i]?.color)
256
256
  return {
257
257
  ...(typeof template === 'object' ? template : {}),
258
258
  type: 'bar' as const,
259
259
  datasetIndex: i,
260
- name: seriesNames?.[i] ?? `Series ${i + 1}`,
260
+ name: series?.[i]?.name ?? `Series ${i + 1}`,
261
261
  encode: { x: 0, y: 1 },
262
262
  barCategoryGap: '0%',
263
263
  emphasis: { focus: 'series' },
264
264
  itemStyle: dimItemStyle,
265
+ ...(overrideColor ? { color: overrideColor } : {}),
265
266
  }
266
267
  }),
267
268
  legend: { ...baseLegend, show: hasLegend },
@@ -1,5 +1,6 @@
1
1
  import type { Theme } from '@mui/material'
2
2
  import type { EChartsOption } from 'echarts'
3
+ import type { WidgetSeries } from '../types'
3
4
 
4
5
  /**
5
6
  * Histogram widget data — array of series, each a flat array of bin counts.
@@ -19,15 +20,18 @@ export interface HistogramOptionsInput {
19
20
  * Combined inputs for the histogram option factory creator. Carries
20
21
  * everything the widget needs across BOTH phases — the structural-build
21
22
  * (`theme`, `formatter`, `optionsOverride`) AND the data merge (`ticks`,
22
- * `seriesNames`, `labelFormatter`, `selection`).
23
+ * `series`, `labelFormatter`, `selection`).
23
24
  */
24
25
  export interface HistogramOptionFactoryInput {
25
26
  theme: Theme
26
27
  formatter?: (value: number) => string
27
28
  /** Bin boundaries — length = bins + 1. */
28
29
  ticks: readonly number[]
29
- /** Optional series names — drives the legend. */
30
- seriesNames?: readonly string[]
30
+ /**
31
+ * Per-series metadata — drives the legend, `series[i].name`, and
32
+ * (when `color` is set) a per-series colour override on the bars.
33
+ */
34
+ series?: readonly WidgetSeries[]
31
35
  /**
32
36
  * Bin-range label transform (e.g. `"0–10"` → `"[0–10)"`). Distinct from
33
37
  * the standard `OptionFactoryContext.labelFormatter` (which operates on
@@ -199,3 +199,6 @@ export type {
199
199
  TableSortDirection,
200
200
  TableSortState,
201
201
  } from './table'
202
+
203
+ // Canonical cross-widget series shape — see `widgets-v2/types.ts`.
204
+ export type { WidgetSeries } from './types'
@@ -147,8 +147,11 @@ describe('createPieOptionFactory — single-series (donut)', () => {
147
147
  expect(single.legend?.show).toBe(true)
148
148
  })
149
149
 
150
- it('uses seriesNames[0] for the single series name when provided', () => {
151
- const merge = createPieOptionFactory({ theme, seriesNames: ['2024'] })
150
+ it('uses series[0].name for the single series name when provided', () => {
151
+ const merge = createPieOptionFactory({
152
+ theme,
153
+ series: [{ name: '2024' }],
154
+ })
152
155
  const out = merge({}, [[{ name: 'A', value: 1 }]]) as {
153
156
  series: { name: string }[]
154
157
  }
@@ -507,16 +510,29 @@ describe('createPieOptionFactory — multi-series fallback (horizontal bar)', ()
507
510
  ).toBe('#BBB')
508
511
  })
509
512
 
510
- it('uses seriesNames for series.name when provided', () => {
513
+ it('uses series[i].name for series.name when provided', () => {
511
514
  const merge = createPieOptionFactory({
512
515
  theme,
513
- seriesNames: ['2024', '2025'],
516
+ series: [{ name: '2024' }, { name: '2025' }],
514
517
  })
515
518
  const out = merge({}, MULTI) as { series: { name: string }[] }
516
519
  expect(out.series[0]?.name).toBe('2024')
517
520
  expect(out.series[1]?.name).toBe('2025')
518
521
  })
519
522
 
523
+ it('applies series[i].color as a per-series colour in the multi-series bar fusion', () => {
524
+ const merge = createPieOptionFactory({
525
+ theme,
526
+ series: [
527
+ { name: '2024', color: '#ff0000' },
528
+ { name: '2025' }, // no color
529
+ ],
530
+ })
531
+ const out = merge({}, MULTI) as { series: { color?: string }[] }
532
+ expect(out.series[0]?.color).toBe('#ff0000')
533
+ expect(out.series[1]?.color).toBeUndefined()
534
+ })
535
+
520
536
  it('keeps legend.show = true (the bar layout still surfaces series in the legend)', () => {
521
537
  const merge = createPieOptionFactory({ theme })
522
538
  const out = merge({}, MULTI) as { legend?: { show?: boolean } }
@@ -11,7 +11,8 @@ import {
11
11
  niceNum,
12
12
  } from '../../widgets/utils/chart-config'
13
13
  import type { OptionFactory, OptionFactoryContext } from '../echart'
14
- import { mergeOptions } from '../utils'
14
+ import { mergeOptions, resolveThemeColor } from '../utils'
15
+ import type { WidgetSeries } from '../types'
15
16
  import type {
16
17
  PieEChartsOption,
17
18
  PieOptionFactoryInput,
@@ -125,8 +126,7 @@ export function pieOptions({
125
126
  export function createPieOptionFactory(
126
127
  options: PieOptionFactoryInput,
127
128
  ): OptionFactory {
128
- const { theme, formatter, labelFormatter, optionsOverride, seriesNames } =
129
- options
129
+ const { theme, formatter, labelFormatter, optionsOverride, series } = options
130
130
  const radius = options.radius ?? DEFAULT_RADIUS
131
131
  const selection = options.selection
132
132
  const selectionSet =
@@ -153,7 +153,7 @@ export function createPieOptionFactory(
153
153
  option,
154
154
  seriesArr,
155
155
  theme,
156
- seriesNames,
156
+ series,
157
157
  ctx,
158
158
  selectionSet,
159
159
  )
@@ -162,7 +162,7 @@ export function createPieOptionFactory(
162
162
  option,
163
163
  seriesArr,
164
164
  radius,
165
- seriesNames,
165
+ series,
166
166
  ctx,
167
167
  selectionSet,
168
168
  )
@@ -179,7 +179,7 @@ function buildSingleSeriesPieFusion(
179
179
  option: EChartsOption,
180
180
  seriesArr: PieWidgetData,
181
181
  radius: readonly [string, string],
182
- seriesNames: readonly string[] | undefined,
182
+ series: readonly WidgetSeries[] | undefined,
183
183
  ctx: OptionFactoryContext | undefined,
184
184
  selectionSet: Set<string | number> | null,
185
185
  ): EChartsOption {
@@ -270,7 +270,7 @@ function buildSingleSeriesPieFusion(
270
270
  ...templateObj,
271
271
  type: 'pie' as const,
272
272
  datasetIndex: i,
273
- name: seriesNames?.[i] ?? `Series ${i + 1}`,
273
+ name: series?.[i]?.name ?? `Series ${i + 1}`,
274
274
  radius: [...radius],
275
275
  // Lift the donut up so the bottom-anchored legend has the
276
276
  // vertical real estate to wrap to multiple rows for long
@@ -312,7 +312,7 @@ function buildMultiSeriesBarFusion(
312
312
  option: EChartsOption,
313
313
  seriesArr: PieWidgetData,
314
314
  theme: Theme,
315
- seriesNames: readonly string[] | undefined,
315
+ series: readonly WidgetSeries[] | undefined,
316
316
  ctx: OptionFactoryContext | undefined,
317
317
  selectionSet: Set<string | number> | null,
318
318
  ): EChartsOption {
@@ -365,15 +365,19 @@ function buildMultiSeriesBarFusion(
365
365
  // Drop pie-specific structural keys that shouldn't render in the
366
366
  // bar fallback. ECharts ignores undefined keys, so this is a clean
367
367
  // override.
368
- series: seriesArr.map((_, i) => ({
369
- datasetIndex: i,
370
- type: 'bar' as const,
371
- name: seriesNames?.[i] ?? `Series ${i + 1}`,
372
- barMaxWidth: 100,
373
- emphasis: { focus: 'series' },
374
- ...buildSeriesLabelConfig(formatter, 'x'),
375
- itemStyle: { color: barColorFn },
376
- })),
368
+ series: seriesArr.map((_, i) => {
369
+ const overrideColor = resolveThemeColor(theme, series?.[i]?.color)
370
+ return {
371
+ datasetIndex: i,
372
+ type: 'bar' as const,
373
+ name: series?.[i]?.name ?? `Series ${i + 1}`,
374
+ barMaxWidth: 100,
375
+ emphasis: { focus: 'series' },
376
+ ...buildSeriesLabelConfig(formatter, 'x'),
377
+ itemStyle: { color: barColorFn },
378
+ ...(overrideColor ? { color: overrideColor } : {}),
379
+ }
380
+ }),
377
381
  xAxis: {
378
382
  type: 'value',
379
383
  // Closures over the pre-computed nice bounds — ECharts calls them
@@ -1,5 +1,6 @@
1
1
  import type { Theme } from '@mui/material'
2
2
  import type { EChartsOption } from 'echarts'
3
+ import type { WidgetSeries } from '../types'
3
4
 
4
5
  /** A single slice of a pie series. */
5
6
  export interface PieDatum {
@@ -23,7 +24,7 @@ export interface PieOptionsInput {
23
24
  * Combined inputs for the pie option factory creator. Carries everything
24
25
  * the widget needs across BOTH phases — the structural-build (`theme`,
25
26
  * `formatter`, `labelFormatter`, `optionsOverride`) AND the data merge
26
- * (`seriesNames`, `radius`, `selection`). The merger emits different
27
+ * (`series`, `radius`, `selection`). The merger emits different
27
28
  * chart shapes by series count: single → donut, multi → horizontal-bar
28
29
  * fallback (mirrors v1 pie); both branches read `theme` for styling.
29
30
  */
@@ -31,8 +32,14 @@ export interface PieOptionFactoryInput {
31
32
  theme: Theme
32
33
  formatter?: (value: number) => string
33
34
  labelFormatter?: (value: string | number) => string | number
34
- /** Series names — drives the legend and series.name. */
35
- seriesNames?: readonly string[]
35
+ /**
36
+ * Per-series metadata — drives the legend, `series[i].name`, and (in
37
+ * the multi-series bar fallback) per-series colour overrides.
38
+ * Single-series donuts always use the per-slice palette regardless of
39
+ * any `series[0].color`, since the donut palette is keyed by data
40
+ * index, not series index.
41
+ */
42
+ series?: readonly WidgetSeries[]
36
43
  /**
37
44
  * Inner/outer radius (percent). Default `['58%', '74%']` produces a
38
45
  * donut sized to leave room for the wrappable bottom legend. Set
@@ -106,20 +106,26 @@ describe('<RangeUI>', () => {
106
106
  ).toBe(true)
107
107
  })
108
108
 
109
- it('commits a typed value to onChange on Enter, clamped to [min, max]', () => {
109
+ it('commits a typed value on Enter (fires both onChange and onChangeCommitted), clamped to [min, max]', () => {
110
+ // A text-input edit is both a value change and a final commit, so it
111
+ // notifies `onChange` AND `onChangeCommitted` — consumers that only wired
112
+ // `onChange` (the pre-`onChangeCommitted` API) still update.
110
113
  const onChange = vi.fn()
114
+ const onChangeCommitted = vi.fn()
111
115
  render(
112
116
  <RangeUI
113
117
  items={[{ min: 0, max: 100, value: [10, 90] }]}
114
118
  onChange={onChange}
119
+ onChangeCommitted={onChangeCommitted}
115
120
  />,
116
121
  )
117
122
  const min = screen.getByLabelText('Minimum value')
118
123
  // Type 150 — should clamp to max (100), then preserve ordering (low <= high).
119
124
  fireEvent.change(min, { target: { value: '150' } })
120
125
  fireEvent.keyDown(min, { key: 'Enter' })
126
+ expect(onChangeCommitted).toHaveBeenCalled()
121
127
  expect(onChange).toHaveBeenCalled()
122
- const last = onChange.mock.calls.at(-1) as [
128
+ const last = onChangeCommitted.mock.calls.at(-1) as [
123
129
  number,
124
130
  readonly [number, number],
125
131
  ]
@@ -11,11 +11,19 @@ import { styles } from './style'
11
11
  export interface RangeUIProps {
12
12
  items: readonly RangeDataItem[]
13
13
  /**
14
- * Fires when the user drags a thumb or commits a new value via the text
15
- * inputs. Per the destination-owned principle, the consumer owns the
16
- * value this widget surfaces changes but does not store them.
14
+ * Fires on every pointer tick while a thumb is being dragged (mirrors
15
+ * MUI `<Slider>`'s `onChange`). Use this to update local UI state
16
+ * not to persist expensive operations like remote queries.
17
17
  */
18
18
  onChange?: (index: number, value: RangeItemValue) => void
19
+ /**
20
+ * Fires once when the user *releases* a slider thumb after dragging,
21
+ * after an arrow-key adjustment commits, or when the text inputs
22
+ * blur / Enter. Mirrors MUI `<Slider>`'s `onChangeCommitted`. Use
23
+ * this for side-effects you want to throttle to "drag end" — e.g.
24
+ * writing the value to a source filter that triggers refetches.
25
+ */
26
+ onChangeCommitted?: (index: number, value: RangeItemValue) => void
19
27
  /** Number formatter for the slider tooltip and the text input display. */
20
28
  formatter?: (value: number) => string
21
29
  }
@@ -28,7 +36,12 @@ type Bound = 'min' | 'max'
28
36
  * UX. Each item is always a two-thumb range; supply `value` to seed the
29
37
  * starting selection, otherwise it defaults to `[min, max]`.
30
38
  */
31
- export function RangeUI({ items, onChange, formatter }: RangeUIProps) {
39
+ export function RangeUI({
40
+ items,
41
+ onChange,
42
+ onChangeCommitted,
43
+ formatter,
44
+ }: RangeUIProps) {
32
45
  const fmt = formatter ?? ((n: number) => String(n))
33
46
  return (
34
47
  <Box sx={styles.root}>
@@ -42,6 +55,7 @@ export function RangeUI({ items, onChange, formatter }: RangeUIProps) {
42
55
  item={item}
43
56
  fmt={fmt}
44
57
  onChange={onChange}
58
+ onChangeCommitted={onChangeCommitted}
45
59
  />
46
60
  ))}
47
61
  </Box>
@@ -53,23 +67,57 @@ interface RangeRowProps {
53
67
  item: RangeDataItem
54
68
  fmt: (n: number) => string
55
69
  onChange?: (index: number, value: RangeItemValue) => void
70
+ onChangeCommitted?: (index: number, value: RangeItemValue) => void
56
71
  }
57
72
 
58
- function RangeRow({ index, item, fmt, onChange }: RangeRowProps) {
73
+ function RangeRow({
74
+ index,
75
+ item,
76
+ fmt,
77
+ onChange,
78
+ onChangeCommitted,
79
+ }: RangeRowProps) {
59
80
  const current: readonly [number, number] = item.value ?? [item.min, item.max]
60
81
  const [editing, setEditing] = useState<'' | Bound>('')
61
82
 
62
- const commit = useCallback(
63
- (next: readonly [number, number]) => {
64
- // Clamp inside [min, max] and keep `low <= high`.
83
+ // Clamp inside [min, max] and keep `low <= high`. Pulled out so both the
84
+ // live `onChange` path and the commit path share the same normalization.
85
+ const normalize = useCallback(
86
+ (next: readonly [number, number]): readonly [number, number] => {
65
87
  const [lowRaw, highRaw] = next
66
88
  const low = Math.min(Math.max(lowRaw, item.min), item.max)
67
89
  const high = Math.min(Math.max(highRaw, item.min), item.max)
68
- const ordered: readonly [number, number] =
69
- low <= high ? [low, high] : [high, low]
70
- onChange?.(index, ordered)
90
+ return low <= high ? [low, high] : [high, low]
91
+ },
92
+ [item.min, item.max],
93
+ )
94
+
95
+ const commit = useCallback(
96
+ (next: readonly [number, number]) => {
97
+ onChange?.(index, normalize(next))
98
+ },
99
+ [index, normalize, onChange],
100
+ )
101
+
102
+ const commitFinal = useCallback(
103
+ (next: readonly [number, number]) => {
104
+ onChangeCommitted?.(index, normalize(next))
71
105
  },
72
- [index, item.min, item.max, onChange],
106
+ [index, normalize, onChangeCommitted],
107
+ )
108
+
109
+ // A text-input commit (blur / Enter) is both a value change AND a final
110
+ // commit, so it fires `onChange` *and* `onChangeCommitted`. Firing both
111
+ // keeps the widget responsive for consumers that only wired `onChange`
112
+ // (the pre-`onChangeCommitted` API) — without it, typing a value and
113
+ // pressing Enter would silently no-op for them.
114
+ const commitText = useCallback(
115
+ (next: readonly [number, number]) => {
116
+ const value = normalize(next)
117
+ onChange?.(index, value)
118
+ onChangeCommitted?.(index, value)
119
+ },
120
+ [index, normalize, onChange, onChangeCommitted],
73
121
  )
74
122
 
75
123
  const handleSlider = (_: Event, raw: number | number[]) => {
@@ -81,6 +129,16 @@ function RangeRow({ index, item, fmt, onChange }: RangeRowProps) {
81
129
  commit([low, high])
82
130
  }
83
131
 
132
+ const handleSliderCommitted = (
133
+ _: Event | React.SyntheticEvent,
134
+ raw: number | number[],
135
+ ) => {
136
+ if (!Array.isArray(raw)) return
137
+ const low = raw[0] ?? item.min
138
+ const high = raw[1] ?? item.max
139
+ commitFinal([low, high])
140
+ }
141
+
84
142
  return (
85
143
  <Box sx={styles.item}>
86
144
  <Box sx={styles.sliderContainer}>
@@ -98,9 +156,13 @@ function RangeRow({ index, item, fmt, onChange }: RangeRowProps) {
98
156
  valueLabelDisplay='auto'
99
157
  valueLabelFormat={fmt}
100
158
  onChange={handleSlider}
159
+ onChangeCommitted={handleSliderCommitted}
101
160
  />
102
161
  </Box>
103
162
  <Box sx={styles.inputsRow}>
163
+ {/* Text-input commits (blur / Enter) are final, but also notify
164
+ `onChange` — see `commitText` — so consumers that only wired
165
+ `onChange` still update on a typed value. */}
104
166
  <BoundInput
105
167
  // Bumping the key on a fresh external value resets the local
106
168
  // editing state — matches v1's RangeItem behaviour.
@@ -111,7 +173,7 @@ function RangeRow({ index, item, fmt, onChange }: RangeRowProps) {
111
173
  fmt={fmt}
112
174
  editing={editing}
113
175
  setEditing={setEditing}
114
- commit={commit}
176
+ commit={commitText}
115
177
  current={current}
116
178
  ariaLabel='Minimum value'
117
179
  />
@@ -123,7 +185,7 @@ function RangeRow({ index, item, fmt, onChange }: RangeRowProps) {
123
185
  fmt={fmt}
124
186
  editing={editing}
125
187
  setEditing={setEditing}
126
- commit={commit}
188
+ commit={commitText}
127
189
  current={current}
128
190
  ariaLabel='Maximum value'
129
191
  />
@@ -144,6 +206,11 @@ interface BoundInputProps {
144
206
  fmt: (n: number) => string
145
207
  editing: '' | Bound
146
208
  setEditing: (next: '' | Bound) => void
209
+ /**
210
+ * Called when the user commits a new value (blur / Enter). Text input
211
+ * edits never produce intermediate "live" values, so the consumer
212
+ * only needs one callback — the row wires this to `commitFinal`.
213
+ */
147
214
  commit: (next: readonly [number, number]) => void
148
215
  current: readonly [number, number]
149
216
  ariaLabel: string
@@ -17,23 +17,28 @@ const rangeSelector = (s: {
17
17
 
18
18
  export interface RangeProps {
19
19
  /**
20
- * Fires when the user moves a slider thumb or commits a new value via the
21
- * text inputs. Receives the item index in `state.data` and the new
22
- * `[low, high]` tuple. The consumer owns the data state — the widget
23
- * surfaces changes but does not persist them.
20
+ * Fires on every pointer tick while a thumb is being dragged. Use this
21
+ * for cheap UI updates (local state, optimistic display).
24
22
  */
25
23
  onChange?: (index: number, value: RangeItemValue) => void
24
+ /**
25
+ * Fires once when the user *releases* a slider thumb (or when the text
26
+ * inputs blur / Enter, or when an arrow-key adjustment settles). Use
27
+ * this for expensive side-effects you want throttled to drag end —
28
+ * e.g. writing the value to a source filter that refetches widgets.
29
+ */
30
+ onChangeCommitted?: (index: number, value: RangeItemValue) => void
26
31
  }
27
32
 
28
33
  /**
29
34
  * Stateful Range bridge — reads `data` (post-pipeline) and `formatter` from
30
35
  * the per-widget store and forwards them to the pure {@link RangeUI}. Per
31
36
  * the destination-owned principle, value changes flow back through
32
- * `onChange` the consumer is expected to update the data prop on
33
- * `<Provider>`. Use `disabled` on individual `RangeDataItem`s to disable
34
- * specific rows.
37
+ * `onChange` (per-tick) and `onChangeCommitted` (drag end) the consumer
38
+ * is expected to update the data prop on `<Provider>`. Use `disabled` on
39
+ * individual `RangeDataItem`s to disable specific rows.
35
40
  */
36
- export function Range({ onChange }: RangeProps) {
41
+ export function Range({ onChange, onChangeCommitted }: RangeProps) {
37
42
  const id = useWidgetId()
38
43
  const slice = useWidgetShallow(id, rangeSelector)
39
44
  return (
@@ -41,6 +46,7 @@ export function Range({ onChange }: RangeProps) {
41
46
  items={slice.data}
42
47
  formatter={slice.formatter}
43
48
  onChange={onChange}
49
+ onChangeCommitted={onChangeCommitted}
44
50
  />
45
51
  )
46
52
  }
@@ -151,10 +151,10 @@ describe('createScatterplotOptionFactory', () => {
151
151
  expect(multi.legend.show).toBe(true)
152
152
  })
153
153
 
154
- it('uses seriesNames for series.name when provided', () => {
154
+ it('uses series[i].name for series.name when provided', () => {
155
155
  const merge = createScatterplotOptionFactory({
156
156
  theme,
157
- seriesNames: ['Group A', 'Group B'],
157
+ series: [{ name: 'Group A' }, { name: 'Group B' }],
158
158
  })
159
159
  const out = merge({}, [[[1, 2]], [[3, 4]]]) as {
160
160
  series: { name: string }[]
@@ -163,7 +163,19 @@ describe('createScatterplotOptionFactory', () => {
163
163
  expect(out.series[1]?.name).toBe('Group B')
164
164
  })
165
165
 
166
- it('falls back to `Series N` naming when seriesNames is missing', () => {
166
+ it('applies series[i].color as a per-series colour override', () => {
167
+ const merge = createScatterplotOptionFactory({
168
+ theme,
169
+ series: [{ name: 'Group A', color: '#ff0000' }, { name: 'Group B' }],
170
+ })
171
+ const out = merge({}, [[[1, 2]], [[3, 4]]]) as {
172
+ series: { color?: string }[]
173
+ }
174
+ expect(out.series[0]?.color).toBe('#ff0000')
175
+ expect(out.series[1]?.color).toBeUndefined()
176
+ })
177
+
178
+ it('falls back to `Series N` naming when series is missing', () => {
167
179
  const merge = createScatterplotOptionFactory({ theme })
168
180
  const out = merge({}, [[[1, 2]], [[3, 4]]]) as {
169
181
  series: { name: string }[]