@carto/ps-react-ui 4.25.0 → 4.27.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 (91) hide show
  1. package/dist/actions-DON6fSCh.js +1289 -0
  2. package/dist/actions-DON6fSCh.js.map +1 -0
  3. package/dist/category-BoT5FHHp.js +950 -0
  4. package/dist/category-BoT5FHHp.js.map +1 -0
  5. package/dist/item-color-C1cu6EJd.js +20 -0
  6. package/dist/item-color-C1cu6EJd.js.map +1 -0
  7. package/dist/scale-0elCv3tk.js +29 -0
  8. package/dist/scale-0elCv3tk.js.map +1 -0
  9. package/dist/types/widgets-v2/actions/index.d.ts +1 -1
  10. package/dist/types/widgets-v2/actions/relative-data/index.d.ts +1 -0
  11. package/dist/types/widgets-v2/actions/relative-data/labels.d.ts +3 -0
  12. package/dist/types/widgets-v2/actions/relative-data/relative-data.d.ts +16 -2
  13. package/dist/types/widgets-v2/actions/relative-data/scale.d.ts +26 -0
  14. package/dist/types/widgets-v2/actions/relative-data/style.d.ts +12 -0
  15. package/dist/types/widgets-v2/bar/types.d.ts +6 -0
  16. package/dist/types/widgets-v2/category/category.d.ts +2 -1
  17. package/dist/types/widgets-v2/histogram/bins.d.ts +7 -0
  18. package/dist/types/widgets-v2/histogram/download.d.ts +2 -2
  19. package/dist/types/widgets-v2/histogram/index.d.ts +2 -1
  20. package/dist/types/widgets-v2/histogram/options.d.ts +1 -1
  21. package/dist/types/widgets-v2/histogram/transforms.d.ts +5 -4
  22. package/dist/types/widgets-v2/histogram/types.d.ts +22 -5
  23. package/dist/types/widgets-v2/index.d.ts +2 -2
  24. package/dist/types/widgets-v2/pie/types.d.ts +6 -0
  25. package/dist/types/widgets-v2/scatterplot/download.d.ts +2 -2
  26. package/dist/types/widgets-v2/scatterplot/index.d.ts +2 -1
  27. package/dist/types/widgets-v2/scatterplot/points.d.ts +7 -0
  28. package/dist/types/widgets-v2/scatterplot/transforms.d.ts +4 -3
  29. package/dist/types/widgets-v2/scatterplot/types.d.ts +21 -2
  30. package/dist/types/widgets-v2/timeseries/types.d.ts +6 -0
  31. package/dist/types/widgets-v2/utils/item-color.d.ts +26 -0
  32. package/dist/widgets-v2/actions.js +1 -1
  33. package/dist/widgets-v2/bar.js +91 -81
  34. package/dist/widgets-v2/bar.js.map +1 -1
  35. package/dist/widgets-v2/category.js +1 -1
  36. package/dist/widgets-v2/histogram.js +133 -100
  37. package/dist/widgets-v2/histogram.js.map +1 -1
  38. package/dist/widgets-v2/pie.js +98 -78
  39. package/dist/widgets-v2/pie.js.map +1 -1
  40. package/dist/widgets-v2/scatterplot.js +151 -110
  41. package/dist/widgets-v2/scatterplot.js.map +1 -1
  42. package/dist/widgets-v2/timeseries.js +96 -86
  43. package/dist/widgets-v2/timeseries.js.map +1 -1
  44. package/dist/widgets-v2.js +2 -2
  45. package/dist/widgets-v2.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +77 -13
  48. package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +48 -5
  49. package/src/widgets-v2/actions/index.ts +1 -0
  50. package/src/widgets-v2/actions/relative-data/index.ts +1 -0
  51. package/src/widgets-v2/actions/relative-data/labels.ts +6 -0
  52. package/src/widgets-v2/actions/relative-data/relative-data.test.tsx +78 -1
  53. package/src/widgets-v2/actions/relative-data/relative-data.tsx +137 -20
  54. package/src/widgets-v2/actions/relative-data/scale.ts +48 -0
  55. package/src/widgets-v2/actions/relative-data/style.ts +10 -0
  56. package/src/widgets-v2/bar/options.test.ts +122 -0
  57. package/src/widgets-v2/bar/options.ts +31 -16
  58. package/src/widgets-v2/bar/types.ts +6 -0
  59. package/src/widgets-v2/category/category.relative-data.test.tsx +51 -63
  60. package/src/widgets-v2/category/category.tsx +25 -11
  61. package/src/widgets-v2/histogram/bins.ts +22 -0
  62. package/src/widgets-v2/histogram/download.test.ts +10 -0
  63. package/src/widgets-v2/histogram/download.ts +7 -3
  64. package/src/widgets-v2/histogram/index.ts +3 -0
  65. package/src/widgets-v2/histogram/options.test.ts +62 -0
  66. package/src/widgets-v2/histogram/options.ts +44 -19
  67. package/src/widgets-v2/histogram/transforms.test.ts +21 -7
  68. package/src/widgets-v2/histogram/transforms.ts +26 -12
  69. package/src/widgets-v2/histogram/types.ts +25 -5
  70. package/src/widgets-v2/index.ts +7 -1
  71. package/src/widgets-v2/pie/options.test.ts +91 -0
  72. package/src/widgets-v2/pie/options.ts +50 -20
  73. package/src/widgets-v2/pie/types.ts +6 -0
  74. package/src/widgets-v2/scatterplot/download.test.ts +9 -0
  75. package/src/widgets-v2/scatterplot/download.ts +5 -3
  76. package/src/widgets-v2/scatterplot/index.ts +3 -0
  77. package/src/widgets-v2/scatterplot/options.test.ts +90 -0
  78. package/src/widgets-v2/scatterplot/options.ts +53 -15
  79. package/src/widgets-v2/scatterplot/points.ts +35 -0
  80. package/src/widgets-v2/scatterplot/transforms.test.ts +19 -0
  81. package/src/widgets-v2/scatterplot/transforms.ts +23 -18
  82. package/src/widgets-v2/scatterplot/types.ts +25 -2
  83. package/src/widgets-v2/timeseries/options.test.ts +65 -0
  84. package/src/widgets-v2/timeseries/options.ts +38 -19
  85. package/src/widgets-v2/timeseries/types.ts +6 -0
  86. package/src/widgets-v2/utils/item-color.test.ts +74 -0
  87. package/src/widgets-v2/utils/item-color.ts +55 -0
  88. package/dist/actions-B1ss030_.js +0 -1167
  89. package/dist/actions-B1ss030_.js.map +0 -1
  90. package/dist/category-DuKx_CJX.js +0 -933
  91. package/dist/category-DuKx_CJX.js.map +0 -1
@@ -36,15 +36,20 @@ describe('toRelativeHistogramData', () => {
36
36
  expect(out[0]?.[1]).toBeCloseTo(-49.74874, 4)
37
37
  })
38
38
 
39
- it('returns the input unchanged when shape does not match number[][]', () => {
39
+ it('returns the input unchanged when shape does not match HistogramBin[][]', () => {
40
40
  expect(toRelativeHistogramData(null)).toBe(null)
41
41
  expect(toRelativeHistogramData('not an array')).toBe('not an array')
42
- // Series that isn't a flat number array (e.g. named-value or tuple)
43
- // is returned as-is — keeps the transform safe to attach to a
44
- // widget whose shape changes through other actions.
45
- expect(toRelativeHistogramData([[{ name: 'a', value: 10 }]])).toEqual([
46
- [{ name: 'a', value: 10 }],
47
- ])
42
+ // Series that isn't a list of bins (e.g. strings or tuples) is returned
43
+ // as-is — keeps the transform safe to attach to a widget whose shape
44
+ // changes through other actions.
45
+ expect(toRelativeHistogramData([['a', 'b']])).toEqual([['a', 'b']])
46
+ expect(toRelativeHistogramData([[[1, 2]]])).toEqual([[[1, 2]]])
47
+ })
48
+
49
+ it('relativizes object bins, keeping their colour', () => {
50
+ expect(
51
+ toRelativeHistogramData([[{ value: 5, color: '#f00' }, 15]]),
52
+ ).toEqual([[{ value: 25, color: '#f00' }, 75]])
48
53
  })
49
54
  })
50
55
 
@@ -75,6 +80,15 @@ describe('filterHistogramByLockedBins', () => {
75
80
  ])
76
81
  })
77
82
 
83
+ it('zeroes non-locked object bins, keeping their colour', () => {
84
+ expect(
85
+ filterHistogramByLockedBins(
86
+ [[{ value: 10, color: '#f00' }, { value: 20 }, 30]],
87
+ [1],
88
+ ),
89
+ ).toEqual([[{ value: 0, color: '#f00' }, { value: 20 }, 0]])
90
+ })
91
+
78
92
  it('returns input unchanged when input is not array-shaped', () => {
79
93
  expect(filterHistogramByLockedBins('nope', [0])).toBe('nope')
80
94
  })
@@ -1,6 +1,14 @@
1
+ import {
2
+ getHistogramBinValue,
3
+ isHistogramBin,
4
+ withHistogramBinValue,
5
+ } from './bins'
6
+ import type { HistogramBin } from './types'
7
+
1
8
  /**
2
9
  * Histogram-specific `RelativeData` transform. Histogram data is
3
- * `number[][]` — each series is a flat list of bin counts. This
10
+ * `HistogramBin[][]` — each series is a flat list of bin counts (plain
11
+ * numbers or `{ value, color? }`, whose shape is kept). This
4
12
  * rewrites each count to its share of the series total (0–100), so
5
13
  * pairing with `createPercentFormatter` renders the y-axis as
6
14
  * percentages.
@@ -11,27 +19,31 @@
11
19
  * inputs produce sane signed shares-of-magnitude. A series whose total
12
20
  * magnitude is zero (all-zero or empty input) is returned unchanged so
13
21
  * a stalled or empty data set doesn't show misleading 0% values. Inputs
14
- * whose shape isn't `number[][]` fall through untouched (same defensive
22
+ * whose shape isn't `HistogramBin[][]` fall through untouched (same defensive
15
23
  * shape-check pattern as the default transform).
16
24
  */
17
25
  export const toRelativeHistogramData = (input: unknown): unknown => {
18
26
  if (!Array.isArray(input)) return input
19
27
  return input.map((series: unknown): unknown => {
20
- if (!isFlatNumberArray(series)) return series
21
- const total = series.reduce((acc, v) => acc + Math.abs(v), 0)
28
+ if (!isBinArray(series)) return series
29
+ const total = series.reduce<number>(
30
+ (acc, b) => acc + Math.abs(getHistogramBinValue(b)),
31
+ 0,
32
+ )
22
33
  if (total <= 0) return series
23
- return series.map((v) => (v / total) * 100)
34
+ return series.map((b) =>
35
+ withHistogramBinValue(b, (getHistogramBinValue(b) / total) * 100),
36
+ )
24
37
  })
25
38
  }
26
39
 
27
- function isFlatNumberArray(v: unknown): v is number[] {
28
- if (!Array.isArray(v)) return false
29
- return v.every((item) => typeof item === 'number' && Number.isFinite(item))
40
+ function isBinArray(v: unknown): v is HistogramBin[] {
41
+ return Array.isArray(v) && v.every(isHistogramBin)
30
42
  }
31
43
 
32
44
  /**
33
- * `LockSelection` filter for Histogram. Data is `number[][]` — a flat list
34
- * of bin counts per series, positionally paired with `ticks` (bin `i` covers
45
+ * `LockSelection` filter for Histogram. Data is `HistogramBin[][]` — a flat
46
+ * list of bin counts per series, positionally paired with `ticks` (bin `i` covers
35
47
  * `[ticks[i], ticks[i+1])`). Unlike name-keyed widgets, locked-out bins can't
36
48
  * be REMOVED — that would shift every later bin's index and desync it from
37
49
  * its `ticks` range. Zeroing them out instead preserves array length/position,
@@ -47,7 +59,9 @@ export function filterHistogramByLockedBins(
47
59
  if (!Array.isArray(input)) return input
48
60
  const locked = new Set<number>(lockedItems.map(Number))
49
61
  return input.map((series: unknown): unknown => {
50
- if (!isFlatNumberArray(series)) return series
51
- return series.map((count, i) => (locked.has(i) ? count : 0))
62
+ if (!isBinArray(series)) return series
63
+ return series.map((bin, i) =>
64
+ locked.has(i) ? bin : withHistogramBinValue(bin, 0),
65
+ )
52
66
  })
53
67
  }
@@ -3,12 +3,32 @@ import type { EChartsOption } from 'echarts'
3
3
  import type { WidgetSeries } from '../types'
4
4
 
5
5
  /**
6
- * Histogram widget data — array of series, each a flat array of bin counts.
7
- * The option factory's merge phase (see {@link createHistogramOptionFactory})
8
- * combines this with `ticks` to produce a 2-column dataset (`[binLabel, count]`)
9
- * for ECharts.
6
+ * One histogram bin: either its count, or `{ value, color? }` to give the
7
+ * bar its own colour (CSS colour or theme palette path such as
8
+ * `'primary.main'`). A bin `color` wins over the template's
9
+ * `itemStyle.color` and the series colour; selection dimming still applies.
10
10
  */
11
- export type HistogramWidgetData = readonly (readonly number[])[]
11
+ export type HistogramBin = number | HistogramBinObject
12
+
13
+ /** Object form of a {@link HistogramBin}. */
14
+ export interface HistogramBinObject {
15
+ value: number
16
+ color?: string
17
+ }
18
+
19
+ /**
20
+ * Histogram widget data — array of series, each a flat array of bins. The
21
+ * option factory's merge phase (see {@link createHistogramOptionFactory})
22
+ * combines this with `ticks` to produce a 2-column dataset
23
+ * (`[binLabel, count]`) for ECharts.
24
+ *
25
+ * Defaults to plain counts (`number[][]`) so existing code that reads bins
26
+ * as numbers keeps type-checking; use `HistogramWidgetData<HistogramBin>`
27
+ * to also accept `{ value, color? }` bins. Every histogram API (option
28
+ * factory, transforms, download) accepts both forms.
29
+ */
30
+ export type HistogramWidgetData<B extends HistogramBin = number> =
31
+ readonly (readonly B[])[]
12
32
 
13
33
  /** Inputs to the structural-only {@link histogramOptions} builder. */
14
34
  export interface HistogramOptionsInput {
@@ -155,6 +155,7 @@ export type {
155
155
  ZoomToggleProps,
156
156
  BrushToggleProps,
157
157
  RelativeDataProps,
158
+ RelativeScale,
158
159
  DownloadProps,
159
160
  DownloadItem,
160
161
  DownloadContext,
@@ -175,7 +176,12 @@ export type {
175
176
  export type { MarkdownUIProps, MarkdownWidgetData } from './markdown'
176
177
  export type { PieDatum, PieWidgetData } from './pie'
177
178
  export type { TimeseriesDatum, TimeseriesWidgetData } from './timeseries'
178
- export type { ScatterplotDatum, ScatterplotWidgetData } from './scatterplot'
179
+ export type {
180
+ ScatterplotDatum,
181
+ ScatterplotPoint,
182
+ ScatterplotItem,
183
+ ScatterplotWidgetData,
184
+ } from './scatterplot'
179
185
  export type {
180
186
  SpreadUIProps,
181
187
  SpreadDataItem,
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import * as echarts from 'echarts'
2
3
  import { createTheme, type Theme } from '@mui/material'
3
4
  import { createPieOptionFactory, pieOptions } from './options'
4
5
 
@@ -936,3 +937,93 @@ describe('createPieOptionFactory — multi-series fallback (horizontal bar)', ()
936
937
  })
937
938
  })
938
939
  })
940
+
941
+ describe('createPieOptionFactory — per-item colour', () => {
942
+ interface Out {
943
+ series: { itemStyle: { color: (p: unknown) => string } }[]
944
+ }
945
+ const dim = (c: string) => echarts.color.modifyAlpha(c, 0.15)
946
+ const palette = ['#p0', '#p1', '#p2']
947
+
948
+ it('donut: slice colour wins over the palette; template next', () => {
949
+ const merge = createPieOptionFactory({ theme, selection: ['A', 'B'] })
950
+ const out = merge(
951
+ {
952
+ color: palette,
953
+ series: [{ type: 'pie', itemStyle: { color: '#123456' } }],
954
+ },
955
+ [
956
+ [
957
+ { name: 'A', value: 1, color: '#00ff00' },
958
+ { name: 'B', value: 2 },
959
+ { name: 'C', value: 3 },
960
+ ],
961
+ ],
962
+ ) as Out
963
+ const colorFn = out.series[0]!.itemStyle.color
964
+ expect(colorFn({ dataIndex: 0, name: 'A', color: '#x' })).toBe('#00ff00')
965
+ expect(colorFn({ dataIndex: 1, name: 'B', color: '#x' })).toBe('#123456')
966
+ expect(colorFn({ dataIndex: 2, name: 'C', color: '#x' })).toBe(
967
+ dim('#123456'),
968
+ )
969
+ })
970
+
971
+ it('donut: keeps the per-data palette when no item colour is given', () => {
972
+ const merge = createPieOptionFactory({ theme })
973
+ const out = merge({ color: palette }, [
974
+ [
975
+ { name: 'A', value: 1, color: '#00ff00' },
976
+ { name: 'B', value: 2 },
977
+ ],
978
+ ]) as Out
979
+ const colorFn = out.series[0]!.itemStyle.color
980
+ expect(colorFn({ dataIndex: 0, name: 'A', color: '#x' })).toBe('#00ff00')
981
+ expect(colorFn({ dataIndex: 1, name: 'B', color: '#x' })).toBe('#p1')
982
+ })
983
+
984
+ it('donut: template itemStyle.color callback via optionsOverride', () => {
985
+ const factory = createPieOptionFactory({
986
+ theme,
987
+ optionsOverride: {
988
+ series: [
989
+ {
990
+ type: 'pie',
991
+ itemStyle: {
992
+ color: (p: { dataIndex: number }) => `#c${p.dataIndex}`,
993
+ },
994
+ },
995
+ ],
996
+ },
997
+ })
998
+ const out = factory(factory(undefined, undefined), [
999
+ [{ name: 'A', value: 1 }],
1000
+ ]) as Out
1001
+ expect(
1002
+ out.series[0]!.itemStyle.color({ dataIndex: 0, name: 'A', color: '#x' }),
1003
+ ).toBe('#c0')
1004
+ })
1005
+
1006
+ it('multi-series bar fallback: datum colour wins over the series palette', () => {
1007
+ const merge = createPieOptionFactory({ theme, selection: ['B'] })
1008
+ const out = merge({ color: palette }, [
1009
+ [{ name: 'A', value: 1, color: '#00ff00' }],
1010
+ [{ name: 'A', value: 2 }],
1011
+ ]) as Out
1012
+ expect(
1013
+ out.series[0]!.itemStyle.color({
1014
+ dataIndex: 0,
1015
+ seriesIndex: 0,
1016
+ name: 'A',
1017
+ color: '#x',
1018
+ }),
1019
+ ).toBe(dim('#00ff00'))
1020
+ expect(
1021
+ out.series[1]!.itemStyle.color({
1022
+ dataIndex: 0,
1023
+ seriesIndex: 1,
1024
+ name: 'B',
1025
+ color: '#x',
1026
+ }),
1027
+ ).toBe('#p1')
1028
+ })
1029
+ })
@@ -12,6 +12,7 @@ import {
12
12
  } from '../../widgets/utils/chart-config'
13
13
  import type { OptionFactory, OptionFactoryContext } from '../echart'
14
14
  import { mergeOptions, resolveThemeColor } from '../utils'
15
+ import { createItemColorResolver } from '../utils/item-color'
15
16
  import type { WidgetSeries } from '../types'
16
17
  import type {
17
18
  PieEChartsOption,
@@ -168,6 +169,7 @@ export function createPieOptionFactory(
168
169
  return buildSingleSeriesPieFusion(
169
170
  option,
170
171
  seriesArr,
172
+ theme,
171
173
  radius,
172
174
  series,
173
175
  ctx,
@@ -185,6 +187,7 @@ export function createPieOptionFactory(
185
187
  function buildSingleSeriesPieFusion(
186
188
  option: EChartsOption,
187
189
  seriesArr: PieWidgetData,
190
+ theme: Theme,
188
191
  radius: readonly [string, string],
189
192
  series: readonly WidgetSeries[] | undefined,
190
193
  ctx: OptionFactoryContext | undefined,
@@ -231,14 +234,25 @@ function buildSingleSeriesPieFusion(
231
234
  // empty Set — the collapse above (`selection.length === 0 → null`) rules
232
235
  // that out — so there's no "dim everything" case here, unlike a widget
233
236
  // that gives `[]` its own "selected down to nothing" meaning.
234
- const colorFn = (params: CallbackDataParams): string => {
235
- const base = resolvePaletteColor(params)
236
- if (!selectionSet) return base
237
- const datum = params.value as { name?: string | number } | undefined
238
- const name = datum?.name ?? params.name
239
- return name != null && selectionSet.has(name)
240
- ? base
241
- : echarts.color.modifyAlpha(base, 0.15)
237
+ //
238
+ // The base colour is the slice's own `color`, else the template's
239
+ // `itemStyle.color`, else the per-data palette swatch.
240
+ const makeColorFn = (datums: readonly object[], template: unknown) => {
241
+ const baseColor = createItemColorResolver({
242
+ theme,
243
+ datums,
244
+ template,
245
+ fallback: resolvePaletteColor,
246
+ })
247
+ return (params: CallbackDataParams): string => {
248
+ const base = baseColor(params)
249
+ if (!selectionSet) return base
250
+ const datum = params.value as { name?: string | number } | undefined
251
+ const name = datum?.name ?? params.name
252
+ return name != null && selectionSet.has(name)
253
+ ? base
254
+ : echarts.color.modifyAlpha(base, 0.15)
255
+ }
242
256
  }
243
257
 
244
258
  // ECharts anchors EVERY slice's `position: 'center'` label at the same
@@ -316,7 +330,7 @@ function buildSingleSeriesPieFusion(
316
330
  return {
317
331
  ...option,
318
332
  dataset: seriesArr.map((s) => ({ source: s as readonly object[] })),
319
- series: seriesArr.map((_, i) => {
333
+ series: seriesArr.map((s, i) => {
320
334
  const template =
321
335
  (seriesTemplates[i] as object | undefined) ??
322
336
  (broadcastTemplate as object)
@@ -344,7 +358,7 @@ function buildSingleSeriesPieFusion(
344
358
  },
345
359
  itemStyle: {
346
360
  ...templateItemStyle,
347
- color: colorFn,
361
+ color: makeColorFn(s, templateObj),
348
362
  },
349
363
  }
350
364
  }),
@@ -419,17 +433,31 @@ function buildMultiSeriesBarFusion(
419
433
  const palette = Array.isArray(option.color)
420
434
  ? (option.color as readonly (string | undefined)[])
421
435
  : []
422
- const barColorFn = (params: CallbackDataParams): string => {
436
+ // The base colour is the datum's own `color`, else the template's
437
+ // `itemStyle.color`, else the per-series palette swatch.
438
+ const seriesTemplates = Array.isArray(option.series) ? option.series : []
439
+ const paletteColor = (params: CallbackDataParams): string => {
423
440
  const seriesIdx = params.seriesIndex ?? 0
424
441
  const seriesSwatch =
425
442
  palette.length > 0 ? palette[seriesIdx % palette.length] : undefined
426
- const base = (seriesSwatch ?? params.color) as string
427
- if (!selectionSet) return base
428
- const datum = params.value as { name?: string | number } | undefined
429
- const name = datum?.name ?? params.name
430
- return name != null && selectionSet.has(name)
431
- ? base
432
- : echarts.color.modifyAlpha(base, 0.15)
443
+ return (seriesSwatch ?? params.color) as string
444
+ }
445
+ const makeBarColorFn = (datums: readonly object[], template: unknown) => {
446
+ const baseColor = createItemColorResolver({
447
+ theme,
448
+ datums,
449
+ template,
450
+ fallback: paletteColor,
451
+ })
452
+ return (params: CallbackDataParams): string => {
453
+ const base = baseColor(params)
454
+ if (!selectionSet) return base
455
+ const datum = params.value as { name?: string | number } | undefined
456
+ const name = datum?.name ?? params.name
457
+ return name != null && selectionSet.has(name)
458
+ ? base
459
+ : echarts.color.modifyAlpha(base, 0.15)
460
+ }
433
461
  }
434
462
 
435
463
  return {
@@ -444,7 +472,7 @@ function buildMultiSeriesBarFusion(
444
472
  // Drop pie-specific structural keys that shouldn't render in the
445
473
  // bar fallback. ECharts ignores undefined keys, so this is a clean
446
474
  // override.
447
- series: seriesArr.map((_, i) => {
475
+ series: seriesArr.map((s, i) => {
448
476
  const overrideColor = resolveThemeColor(theme, series?.[i]?.color)
449
477
  return {
450
478
  datasetIndex: i,
@@ -454,7 +482,9 @@ function buildMultiSeriesBarFusion(
454
482
  legendHoverLink: false,
455
483
  emphasis: { focus: 'series' },
456
484
  ...buildSeriesLabelConfig(formatter, 'x'),
457
- itemStyle: { color: barColorFn },
485
+ itemStyle: {
486
+ color: makeBarColorFn(s, seriesTemplates[i] ?? seriesTemplates[0]),
487
+ },
458
488
  ...(overrideColor ? { color: overrideColor } : {}),
459
489
  }
460
490
  }),
@@ -6,6 +6,12 @@ import type { WidgetSeries } from '../types'
6
6
  export interface PieDatum {
7
7
  name: string | number
8
8
  value: number
9
+ /**
10
+ * Optional colour for this item (CSS colour or theme palette path such as
11
+ * `'primary.main'`). Wins over the template's `itemStyle.color` and the
12
+ * series colour; selection dimming still applies on top.
13
+ */
14
+ color?: string
9
15
  }
10
16
 
11
17
  /** Pie widget data — one slice array per series (rendered as side-by-side donuts). */
@@ -61,6 +61,15 @@ describe('createScatterplotDownloadConfig', () => {
61
61
  )
62
62
  })
63
63
 
64
+ it('CSV writes x/y of object points (colour is not exported)', async () => {
65
+ const items = createScatterplotDownloadConfig({
66
+ filename: 's',
67
+ getData: () => [[{ x: 1, y: 10, color: '#f00' }, [2, 20]]],
68
+ })
69
+ await items.find((i) => i.id === 'csv')!.resolve({ id: null })
70
+ expect(csvText).toBe('series,x,y\nseries_1,1,10\nseries_1,2,20')
71
+ })
72
+
64
73
  it('CSV honours supplied seriesNames', async () => {
65
74
  const items = createScatterplotDownloadConfig({
66
75
  filename: 's',
@@ -4,7 +4,8 @@ import {
4
4
  type DownloadContext,
5
5
  type DownloadItem,
6
6
  } from '../actions/download'
7
- import type { ScatterplotWidgetData } from './types'
7
+ import { getScatterplotXY } from './points'
8
+ import type { ScatterplotItem, ScatterplotWidgetData } from './types'
8
9
 
9
10
  /**
10
11
  * Download menu items for the Scatterplot widget. Always includes a CSV
@@ -14,7 +15,7 @@ import type { ScatterplotWidgetData } from './types'
14
15
  */
15
16
  export function createScatterplotDownloadConfig(args: {
16
17
  filename: string
17
- getData: (ctx: DownloadContext) => ScatterplotWidgetData
18
+ getData: (ctx: DownloadContext) => ScatterplotWidgetData<ScatterplotItem>
18
19
  seriesNames?: readonly string[]
19
20
  getCaptureEl?: (ctx: DownloadContext) => HTMLElement | null
20
21
  pngPixelRatio?: number
@@ -39,7 +40,8 @@ export function createScatterplotDownloadConfig(args: {
39
40
  const rows: unknown[][] = [['series', 'x', 'y']]
40
41
  for (const [i, series] of data.entries()) {
41
42
  const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
42
- for (const [x, y] of series) {
43
+ for (const point of series) {
44
+ const [x, y] = getScatterplotXY(point)
43
45
  rows.push([seriesName, x, y])
44
46
  }
45
47
  }
@@ -5,8 +5,11 @@ export {
5
5
  toRelativeScatterplotData,
6
6
  filterScatterplotByLockedPoints,
7
7
  } from './transforms'
8
+ export { getScatterplotXY } from './points'
8
9
  export type {
9
10
  ScatterplotDatum,
11
+ ScatterplotPoint,
12
+ ScatterplotItem,
10
13
  ScatterplotWidgetData,
11
14
  ScatterplotOptionsInput,
12
15
  ScatterplotOptionFactoryInput,
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import * as echarts from 'echarts'
2
3
  import { createTheme, type Theme } from '@mui/material'
3
4
  import { createScatterplotOptionFactory, scatterplotOptions } from './options'
4
5
 
@@ -212,6 +213,18 @@ describe('createScatterplotOptionFactory', () => {
212
213
  expect(out.yAxis.min).toBe(0)
213
214
  })
214
215
 
216
+ it("pins the y ends a config transform set (RelativeData 'total')", () => {
217
+ const merge = createScatterplotOptionFactory({ theme })
218
+ const out = merge({ yAxis: { min: -100, max: 100 } }, [
219
+ [
220
+ [1, 47],
221
+ [2, 3],
222
+ ],
223
+ ]) as { yAxis: { min: number; max: number } }
224
+ expect(out.yAxis.max).toBe(100)
225
+ expect(out.yAxis.min).toBe(0)
226
+ })
227
+
215
228
  it('always emits itemStyle.color as a callback (passthrough when no selection)', () => {
216
229
  const merge = createScatterplotOptionFactory({ theme })
217
230
  const out = merge({}, [[[1, 2]]]) as {
@@ -409,3 +422,80 @@ describe('createScatterplotOptionFactory', () => {
409
422
  expect(insideOnly.grid.right).toBe(baseline.grid.right)
410
423
  })
411
424
  })
425
+
426
+ describe('createScatterplotOptionFactory — per-item colour', () => {
427
+ interface Out {
428
+ dataset: { source: number[][] }[]
429
+ series: { itemStyle: { color: (p: unknown) => string } }[]
430
+ xAxis: { min: number; max: number }
431
+ yAxis: { min: number; max: number }
432
+ }
433
+ const dim = (c: string) => echarts.color.modifyAlpha(c, 0.15)
434
+ const data = [
435
+ [{ x: 1, y: 2, color: '#00ff00' }, [3, 4] as const, { x: -5, y: 60 }],
436
+ ]
437
+
438
+ it('flattens object points to [x, y] rows and bounds them', () => {
439
+ const merge = createScatterplotOptionFactory({ theme })
440
+ const out = merge({}, data) as Out
441
+ expect(out.dataset[0]!.source).toEqual([
442
+ [1, 2],
443
+ [3, 4],
444
+ [-5, 60],
445
+ ])
446
+ expect(out.xAxis.min).toBeLessThanOrEqual(-5)
447
+ expect(out.xAxis.max).toBeGreaterThanOrEqual(3)
448
+ expect(out.yAxis.max).toBeGreaterThanOrEqual(60)
449
+ })
450
+
451
+ it('keeps tuple-only series as the dataset source', () => {
452
+ const merge = createScatterplotOptionFactory({ theme })
453
+ const series = [[1, 2] as const, [3, 4] as const]
454
+ const out = merge({}, [series]) as Out
455
+ expect(out.dataset[0]!.source).toBe(series)
456
+ })
457
+
458
+ it('point colour wins over the series colour; template next; dims', () => {
459
+ const merge = createScatterplotOptionFactory({
460
+ theme,
461
+ series: [{ name: 'S', color: '#0000ff' }],
462
+ selection: ['0:0', '0:1'],
463
+ })
464
+ const out = merge(
465
+ { series: [{ type: 'scatter', itemStyle: { color: '#123456' } }] },
466
+ data,
467
+ ) as Out
468
+ const colorFn = out.series[0]!.itemStyle.color
469
+ expect(colorFn({ dataIndex: 0, color: '#0000ff' })).toBe('#00ff00')
470
+ expect(colorFn({ dataIndex: 1, color: '#0000ff' })).toBe('#123456')
471
+ expect(colorFn({ dataIndex: 2, color: '#0000ff' })).toBe(dim('#123456'))
472
+ })
473
+
474
+ it('calls a function template itemStyle.color via optionsOverride', () => {
475
+ const factory = createScatterplotOptionFactory({
476
+ theme,
477
+ optionsOverride: {
478
+ series: [
479
+ {
480
+ type: 'scatter',
481
+ itemStyle: {
482
+ color: (p: { dataIndex: number }) => `#c${p.dataIndex}`,
483
+ },
484
+ },
485
+ ],
486
+ },
487
+ })
488
+ const out = factory(factory(undefined, undefined), [[[1, 2]]]) as Out
489
+ expect(out.series[0]!.itemStyle.color({ dataIndex: 0, color: '#x' })).toBe(
490
+ '#c0',
491
+ )
492
+ })
493
+
494
+ it('keeps the series / palette colour for plain tuples', () => {
495
+ const merge = createScatterplotOptionFactory({ theme })
496
+ const out = merge({}, [[[1, 2]]]) as Out
497
+ expect(
498
+ out.series[0]!.itemStyle.color({ dataIndex: 0, color: '#abc' }),
499
+ ).toBe('#abc')
500
+ })
501
+ })