@carto/ps-react-ui 6.1.1 → 6.3.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 (119) hide show
  1. package/dist/{actions-BWlS5tpP.js → actions-CkxKe8-J.js} +57 -47
  2. package/dist/actions-CkxKe8-J.js.map +1 -0
  3. package/dist/category-DULnoxvk.js +933 -0
  4. package/dist/category-DULnoxvk.js.map +1 -0
  5. package/dist/chat.js +1 -1
  6. package/dist/chat.js.map +1 -1
  7. package/dist/components-CTUpdQuj.js.map +1 -1
  8. package/dist/download-B4sykOFm.js.map +1 -1
  9. package/dist/{echart-CV8F50gn.js → echart-ysYFLa_T.js} +2 -2
  10. package/dist/echart-ysYFLa_T.js.map +1 -0
  11. package/dist/formula-DJS-JLpk.js.map +1 -1
  12. package/dist/{option-builders-DQivvvbb.js → item-color-B7xkIpDZ.js} +26 -9
  13. package/dist/item-color-B7xkIpDZ.js.map +1 -0
  14. package/dist/legend.js.map +1 -1
  15. package/dist/{markdown-Bn2nKCbn.js → markdown-DmvLi3vx.js} +2 -2
  16. package/dist/markdown-DmvLi3vx.js.map +1 -0
  17. package/dist/{markdown-content-kYNLOX0T.js → markdown-content-BpAH_V6S.js} +5 -4
  18. package/dist/markdown-content-BpAH_V6S.js.map +1 -0
  19. package/dist/range-BpU2zBmq.js.map +1 -1
  20. package/dist/smart-tooltip-Dc_rHhyI.js.map +1 -1
  21. package/dist/spread-CQwFIwuz.js.map +1 -1
  22. package/dist/stores-DcBOZlU3.js.map +1 -1
  23. package/dist/stores-mad-9yFn.js.map +1 -1
  24. package/dist/styles-BCEBm7xh.js.map +1 -1
  25. package/dist/table-DEdW_fDj.js.map +1 -1
  26. package/dist/types/widgets/bar/types.d.ts +6 -0
  27. package/dist/types/widgets/category/category-ui.d.ts +10 -2
  28. package/dist/types/widgets/category/category.d.ts +7 -1
  29. package/dist/types/widgets/category/components/category-bar-stacked.d.ts +8 -1
  30. package/dist/types/widgets/category/components/category-bar.d.ts +24 -2
  31. package/dist/types/widgets/category/components/category-row-multi.d.ts +3 -1
  32. package/dist/types/widgets/category/components/category-row-single.d.ts +3 -1
  33. package/dist/types/widgets/category/components/category-row-stacked.d.ts +10 -1
  34. package/dist/types/widgets/category/style.d.ts +33 -6
  35. package/dist/types/widgets/histogram/bins.d.ts +7 -0
  36. package/dist/types/widgets/histogram/download.d.ts +2 -2
  37. package/dist/types/widgets/histogram/index.d.ts +2 -1
  38. package/dist/types/widgets/histogram/options.d.ts +1 -1
  39. package/dist/types/widgets/histogram/transforms.d.ts +5 -4
  40. package/dist/types/widgets/histogram/types.d.ts +22 -5
  41. package/dist/types/widgets/index.d.ts +1 -1
  42. package/dist/types/widgets/pie/types.d.ts +6 -0
  43. package/dist/types/widgets/scatterplot/download.d.ts +2 -2
  44. package/dist/types/widgets/scatterplot/index.d.ts +2 -1
  45. package/dist/types/widgets/scatterplot/points.d.ts +7 -0
  46. package/dist/types/widgets/scatterplot/transforms.d.ts +4 -3
  47. package/dist/types/widgets/scatterplot/types.d.ts +21 -2
  48. package/dist/types/widgets/timeseries/types.d.ts +6 -0
  49. package/dist/types/widgets/utils/item-color.d.ts +26 -0
  50. package/dist/utils-C-eWwOhE.js.map +1 -1
  51. package/dist/widgets/actions.js +1 -1
  52. package/dist/widgets/bar.js +78 -70
  53. package/dist/widgets/bar.js.map +1 -1
  54. package/dist/widgets/category.js +1 -1
  55. package/dist/widgets/echart.js +1 -1
  56. package/dist/widgets/histogram.js +116 -85
  57. package/dist/widgets/histogram.js.map +1 -1
  58. package/dist/widgets/markdown.js +2 -2
  59. package/dist/widgets/pie.js +109 -90
  60. package/dist/widgets/pie.js.map +1 -1
  61. package/dist/widgets/scatterplot.js +131 -92
  62. package/dist/widgets/scatterplot.js.map +1 -1
  63. package/dist/widgets/timeseries.js +82 -74
  64. package/dist/widgets/timeseries.js.map +1 -1
  65. package/dist/widgets.js +5 -5
  66. package/dist/widgets.js.map +1 -1
  67. package/dist/zoom-toggle-MmLjMTEw.js.map +1 -1
  68. package/package.json +2 -2
  69. package/src/widgets/actions/fullscreen/fullscreen-store.test.tsx +77 -13
  70. package/src/widgets/actions/fullscreen/fullscreen.tsx +48 -5
  71. package/src/widgets/bar/options.test.ts +106 -0
  72. package/src/widgets/bar/options.ts +25 -14
  73. package/src/widgets/bar/types.ts +6 -0
  74. package/src/widgets/category/category-ui.test.tsx +139 -0
  75. package/src/widgets/category/category-ui.tsx +64 -11
  76. package/src/widgets/category/category.test.tsx +67 -0
  77. package/src/widgets/category/category.tsx +32 -15
  78. package/src/widgets/category/components/category-bar-stacked.test.tsx +18 -0
  79. package/src/widgets/category/components/category-bar-stacked.tsx +42 -29
  80. package/src/widgets/category/components/category-bar.test.tsx +66 -0
  81. package/src/widgets/category/components/category-bar.tsx +65 -9
  82. package/src/widgets/category/components/category-row-multi.tsx +21 -0
  83. package/src/widgets/category/components/category-row-single.tsx +4 -0
  84. package/src/widgets/category/components/category-row-stacked.tsx +24 -1
  85. package/src/widgets/category/style.ts +58 -14
  86. package/src/widgets/histogram/bins.ts +22 -0
  87. package/src/widgets/histogram/download.test.ts +10 -0
  88. package/src/widgets/histogram/download.ts +7 -3
  89. package/src/widgets/histogram/index.ts +3 -0
  90. package/src/widgets/histogram/options.test.ts +62 -0
  91. package/src/widgets/histogram/options.ts +38 -17
  92. package/src/widgets/histogram/transforms.test.ts +21 -7
  93. package/src/widgets/histogram/transforms.ts +26 -12
  94. package/src/widgets/histogram/types.ts +25 -5
  95. package/src/widgets/index.ts +6 -1
  96. package/src/widgets/pie/options.test.ts +91 -0
  97. package/src/widgets/pie/options.ts +50 -20
  98. package/src/widgets/pie/types.ts +6 -0
  99. package/src/widgets/scatterplot/download.test.ts +9 -0
  100. package/src/widgets/scatterplot/download.ts +5 -3
  101. package/src/widgets/scatterplot/index.ts +3 -0
  102. package/src/widgets/scatterplot/options.test.ts +78 -0
  103. package/src/widgets/scatterplot/options.ts +35 -11
  104. package/src/widgets/scatterplot/points.ts +35 -0
  105. package/src/widgets/scatterplot/transforms.test.ts +19 -0
  106. package/src/widgets/scatterplot/transforms.ts +23 -18
  107. package/src/widgets/scatterplot/types.ts +25 -2
  108. package/src/widgets/timeseries/options.test.ts +65 -0
  109. package/src/widgets/timeseries/options.ts +31 -14
  110. package/src/widgets/timeseries/types.ts +6 -0
  111. package/src/widgets/utils/item-color.test.ts +74 -0
  112. package/src/widgets/utils/item-color.ts +55 -0
  113. package/dist/actions-BWlS5tpP.js.map +0 -1
  114. package/dist/category-alzjKzvQ.js +0 -839
  115. package/dist/category-alzjKzvQ.js.map +0 -1
  116. package/dist/echart-CV8F50gn.js.map +0 -1
  117. package/dist/markdown-Bn2nKCbn.js.map +0 -1
  118. package/dist/markdown-content-kYNLOX0T.js.map +0 -1
  119. package/dist/option-builders-DQivvvbb.js.map +0 -1
@@ -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 '../utils/option-builders'
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 DownloadItem,
5
5
  type WidgetDownloadArgs,
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 item
@@ -13,7 +14,7 @@ import type { ScatterplotWidgetData } from './types'
13
14
  * `html2canvas`.
14
15
  */
15
16
  export function createScatterplotDownloadConfig(
16
- args: WidgetDownloadArgs<ScatterplotWidgetData> & {
17
+ args: WidgetDownloadArgs<ScatterplotWidgetData<ScatterplotItem>> & {
17
18
  seriesNames?: readonly string[]
18
19
  },
19
20
  ): DownloadItem[] {
@@ -26,7 +27,8 @@ export function createScatterplotDownloadConfig(
26
27
  const rows: unknown[][] = [['series', 'x', 'y']]
27
28
  for (const [i, series] of data.entries()) {
28
29
  const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
29
- for (const [x, y] of series) {
30
+ for (const point of series) {
31
+ const [x, y] = getScatterplotXY(point)
30
32
  rows.push([seriesName, x, y])
31
33
  }
32
34
  }
@@ -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
 
@@ -409,3 +410,80 @@ describe('createScatterplotOptionFactory', () => {
409
410
  expect(insideOnly.grid.right).toBe(baseline.grid.right)
410
411
  })
411
412
  })
413
+
414
+ describe('createScatterplotOptionFactory — per-item colour', () => {
415
+ interface Out {
416
+ dataset: { source: number[][] }[]
417
+ series: { itemStyle: { color: (p: unknown) => string } }[]
418
+ xAxis: { min: number; max: number }
419
+ yAxis: { min: number; max: number }
420
+ }
421
+ const dim = (c: string) => echarts.color.modifyAlpha(c, 0.15)
422
+ const data = [
423
+ [{ x: 1, y: 2, color: '#00ff00' }, [3, 4] as const, { x: -5, y: 60 }],
424
+ ]
425
+
426
+ it('flattens object points to [x, y] rows and bounds them', () => {
427
+ const merge = createScatterplotOptionFactory({ theme })
428
+ const out = merge({}, data) as Out
429
+ expect(out.dataset[0]!.source).toEqual([
430
+ [1, 2],
431
+ [3, 4],
432
+ [-5, 60],
433
+ ])
434
+ expect(out.xAxis.min).toBeLessThanOrEqual(-5)
435
+ expect(out.xAxis.max).toBeGreaterThanOrEqual(3)
436
+ expect(out.yAxis.max).toBeGreaterThanOrEqual(60)
437
+ })
438
+
439
+ it('keeps tuple-only series as the dataset source', () => {
440
+ const merge = createScatterplotOptionFactory({ theme })
441
+ const series = [[1, 2] as const, [3, 4] as const]
442
+ const out = merge({}, [series]) as Out
443
+ expect(out.dataset[0]!.source).toBe(series)
444
+ })
445
+
446
+ it('point colour wins over the series colour; template next; dims', () => {
447
+ const merge = createScatterplotOptionFactory({
448
+ theme,
449
+ series: [{ name: 'S', color: '#0000ff' }],
450
+ selection: ['0:0', '0:1'],
451
+ })
452
+ const out = merge(
453
+ { series: [{ type: 'scatter', itemStyle: { color: '#123456' } }] },
454
+ data,
455
+ ) as Out
456
+ const colorFn = out.series[0]!.itemStyle.color
457
+ expect(colorFn({ dataIndex: 0, color: '#0000ff' })).toBe('#00ff00')
458
+ expect(colorFn({ dataIndex: 1, color: '#0000ff' })).toBe('#123456')
459
+ expect(colorFn({ dataIndex: 2, color: '#0000ff' })).toBe(dim('#123456'))
460
+ })
461
+
462
+ it('calls a function template itemStyle.color via optionsOverride', () => {
463
+ const factory = createScatterplotOptionFactory({
464
+ theme,
465
+ optionsOverride: {
466
+ series: [
467
+ {
468
+ type: 'scatter',
469
+ itemStyle: {
470
+ color: (p: { dataIndex: number }) => `#c${p.dataIndex}`,
471
+ },
472
+ },
473
+ ],
474
+ },
475
+ })
476
+ const out = factory(factory(undefined, undefined), [[[1, 2]]]) as Out
477
+ expect(out.series[0]!.itemStyle.color({ dataIndex: 0, color: '#x' })).toBe(
478
+ '#c0',
479
+ )
480
+ })
481
+
482
+ it('keeps the series / palette colour for plain tuples', () => {
483
+ const merge = createScatterplotOptionFactory({ theme })
484
+ const out = merge({}, [[[1, 2]]]) as Out
485
+ expect(
486
+ out.series[0]!.itemStyle.color({ dataIndex: 0, color: '#abc' }),
487
+ ).toBe('#abc')
488
+ })
489
+ })
@@ -12,10 +12,13 @@ import {
12
12
  import { ZOOM_LAYOUT } from '../actions/zoom-toggle'
13
13
  import type { OptionFactory } from '../echart'
14
14
  import { mergeOptions, resolveThemeColor } from '../utils'
15
+ import { createItemColorResolver } from '../utils/item-color'
16
+ import { getScatterplotXY } from './points'
15
17
  import type {
16
18
  ScatterplotEChartsOption,
17
19
  ScatterplotOptionFactoryInput,
18
20
  ScatterplotOptionsInput,
21
+ ScatterplotItem,
19
22
  ScatterplotWidgetData,
20
23
  } from './types'
21
24
 
@@ -156,7 +159,9 @@ export function createScatterplotOptionFactory(
156
159
  : structural
157
160
  }
158
161
 
159
- const seriesArr = Array.isArray(data) ? (data as ScatterplotWidgetData) : []
162
+ const seriesArr = Array.isArray(data)
163
+ ? (data as ScatterplotWidgetData<ScatterplotItem>)
164
+ : []
160
165
  if (seriesArr.length === 0) {
161
166
  return { ...option, dataset: [], series: [] }
162
167
  }
@@ -198,15 +203,26 @@ export function createScatterplotOptionFactory(
198
203
  // Keeping the key always-present means a plain setOption merge swaps
199
204
  // the callback in place, no `replaceMerge` and no entry-animation
200
205
  // flash on selection on/off.
201
- const makeDimColor =
202
- (seriesIdx: number) => (params: CallbackDataParams) => {
203
- const base = params.color as string
206
+ //
207
+ // The base colour is the point's own `color` (object points), else the
208
+ // template's `itemStyle.color`, else the series colour / palette.
209
+ const seriesTemplates = Array.isArray(option.series) ? option.series : []
210
+ const makeDimColor = (seriesIdx: number, datums: readonly unknown[]) => {
211
+ const baseColor = createItemColorResolver({
212
+ theme,
213
+ datums,
214
+ template: seriesTemplates[seriesIdx] ?? seriesTemplates[0],
215
+ fallback: (params) => params.color as string,
216
+ })
217
+ return (params: CallbackDataParams) => {
218
+ const base = baseColor(params)
204
219
  if (!selectionSet) return base
205
220
  const key = `${seriesIdx}:${params.dataIndex}`
206
221
  return selectionSet.has(key)
207
222
  ? base
208
223
  : echarts.color.modifyAlpha(base, 0.15)
209
224
  }
225
+ }
210
226
 
211
227
  // Zoom slider layout: when ZoomToggle has installed `dataZoom`,
212
228
  // reserve grid space and lift any x-slider above the legend (if
@@ -232,9 +248,15 @@ export function createScatterplotOptionFactory(
232
248
  return {
233
249
  ...option,
234
250
  // ECharts dataset.source wants a mutable [number, number][] shape; we
235
- // hold readonly tuples internally, so cast at the boundary.
236
- dataset: seriesArr.map((s) => ({ source: s as unknown as number[][] })),
237
- series: seriesArr.map((_, i) => {
251
+ // hold readonly tuples internally, so cast at the boundary. Object
252
+ // points are flattened to `[x, y]` so the positional encode and the
253
+ // tooltip keep reading tuples.
254
+ dataset: seriesArr.map((s) => ({
255
+ source: (s.every(Array.isArray)
256
+ ? s
257
+ : s.map(getScatterplotXY)) as unknown as number[][],
258
+ })),
259
+ series: seriesArr.map((s, i) => {
238
260
  const overrideColor = resolveThemeColor(theme, series?.[i]?.color)
239
261
  return {
240
262
  type: 'scatter' as const,
@@ -243,7 +265,7 @@ export function createScatterplotOptionFactory(
243
265
  encode: { x: 0, y: 1 },
244
266
  symbolSize,
245
267
  emphasis: { focus: 'series' },
246
- itemStyle: { color: makeDimColor(i) },
268
+ itemStyle: { color: makeDimColor(i, s) },
247
269
  ...(overrideColor ? { color: overrideColor } : {}),
248
270
  }
249
271
  }),
@@ -386,7 +408,9 @@ function buildScatterTooltipFormatter(
386
408
  })
387
409
  }
388
410
 
389
- function computeScatterBounds(seriesArr: ScatterplotWidgetData): {
411
+ function computeScatterBounds(
412
+ seriesArr: ScatterplotWidgetData<ScatterplotItem>,
413
+ ): {
390
414
  niceMinX: number
391
415
  niceMaxX: number
392
416
  niceMinY: number
@@ -398,8 +422,8 @@ function computeScatterBounds(seriesArr: ScatterplotWidgetData): {
398
422
  let maxY = -Infinity
399
423
  for (const series of seriesArr) {
400
424
  for (const point of series) {
401
- const x = point?.[0]
402
- const y = point?.[1]
425
+ if (point == null) continue
426
+ const [x, y] = getScatterplotXY(point)
403
427
  if (typeof x === 'number' && Number.isFinite(x)) {
404
428
  if (x < minX) minX = x
405
429
  if (x > maxX) maxX = x
@@ -0,0 +1,35 @@
1
+ import type { ScatterplotItem } from './types'
2
+
3
+ /** `[x, y]` of a {@link ScatterplotItem}, whichever form it takes. */
4
+ export function getScatterplotXY(
5
+ point: ScatterplotItem,
6
+ ): readonly [number, number] {
7
+ return isTuple(point) ? point : [point.x, point.y]
8
+ }
9
+
10
+ /** Whether `v` is a valid {@link ScatterplotItem} with finite coordinates. */
11
+ export function isScatterplotDatum(v: unknown): v is ScatterplotItem {
12
+ if (Array.isArray(v)) {
13
+ return v.length === 2 && isFiniteNumber(v[0]) && isFiniteNumber(v[1])
14
+ }
15
+ if (v == null || typeof v !== 'object') return false
16
+ const { x, y } = v as { x?: unknown; y?: unknown }
17
+ return isFiniteNumber(x) && isFiniteNumber(y)
18
+ }
19
+
20
+ /** Same point with new coordinates; keeps the object form (and `color`). */
21
+ export function withScatterplotXY(
22
+ point: ScatterplotItem,
23
+ x: number,
24
+ y: number,
25
+ ): ScatterplotItem {
26
+ return isTuple(point) ? [x, y] : { ...point, x, y }
27
+ }
28
+
29
+ function isTuple(point: ScatterplotItem): point is readonly [number, number] {
30
+ return Array.isArray(point)
31
+ }
32
+
33
+ function isFiniteNumber(v: unknown): v is number {
34
+ return typeof v === 'number' && Number.isFinite(v)
35
+ }
@@ -99,6 +99,25 @@ describe('toRelativeScatterplotData', () => {
99
99
  })
100
100
  })
101
101
 
102
+ describe('toRelativeScatterplotData (object points)', () => {
103
+ it('relativizes y of object points, keeping x and colour', () => {
104
+ expect(
105
+ toRelativeScatterplotData([[{ x: 1, y: 5, color: '#f00' }, [2, 15]]]),
106
+ ).toEqual([[{ x: 1, y: 25, color: '#f00' }, [2, 75]]])
107
+ })
108
+ })
109
+
110
+ describe('filterScatterplotByLockedPoints (object points)', () => {
111
+ it('keeps locked object points as they are', () => {
112
+ expect(
113
+ filterScatterplotByLockedPoints(
114
+ [[{ x: 1, y: 2, color: '#f00' }, { x: 3, y: 4 }, [5, 6]]],
115
+ ['0:0', '0:2'],
116
+ ),
117
+ ).toEqual([[{ x: 1, y: 2, color: '#f00' }, [5, 6]]])
118
+ })
119
+ })
120
+
102
121
  describe('filterScatterplotByLockedPoints', () => {
103
122
  const DATA = [
104
123
  [
@@ -1,6 +1,14 @@
1
+ import {
2
+ getScatterplotXY,
3
+ isScatterplotDatum,
4
+ withScatterplotXY,
5
+ } from './points'
6
+ import type { ScatterplotItem } from './types'
7
+
1
8
  /**
2
9
  * Scatterplot-specific `RelativeData` transform. Scatter data is
3
- * `[number, number][]` — each series is a list of `[x, y]` tuples.
10
+ * `ScatterplotItem[][]` — each series is a list of `[x, y]` tuples or
11
+ * `{ x, y, color? }` points (whose shape is kept).
4
12
  * Relative is a values-axis concept, so this rewrites `y` to its
5
13
  * share of the series's total `y` (0–100) and leaves `x` raw — x is
6
14
  * coordinate space, not part of the cohort total.
@@ -11,34 +19,31 @@
11
19
  * y values produce sane signed shares-of-magnitude. A series whose
12
20
  * total y-magnitude is zero (all-zero or empty input) is returned
13
21
  * unchanged so a stalled or empty data set doesn't show misleading 0%
14
- * values. Inputs whose shape isn't `[number, number][]` fall through
22
+ * values. Inputs whose shape isn't `ScatterplotItem[]` fall through
15
23
  * untouched.
16
24
  */
17
25
  export const toRelativeScatterplotData = (input: unknown): unknown => {
18
26
  if (!Array.isArray(input)) return input
19
27
  return input.map((series: unknown): unknown => {
20
- if (!isXyTupleArray(series)) return series
21
- const total = series.reduce((acc, [, y]) => acc + Math.abs(y), 0)
28
+ if (!isPointArray(series)) return series
29
+ const total = series.reduce(
30
+ (acc, p) => acc + Math.abs(getScatterplotXY(p)[1]),
31
+ 0,
32
+ )
22
33
  if (total <= 0) return series
23
- return series.map(([x, y]) => [x, (y / total) * 100] as [number, number])
34
+ return series.map((p) => {
35
+ const [x, y] = getScatterplotXY(p)
36
+ return withScatterplotXY(p, x, (y / total) * 100)
37
+ })
24
38
  })
25
39
  }
26
40
 
27
- function isXyTupleArray(v: unknown): v is [number, number][] {
28
- if (!Array.isArray(v)) return false
29
- return v.every(
30
- (item) =>
31
- Array.isArray(item) &&
32
- item.length === 2 &&
33
- typeof item[0] === 'number' &&
34
- Number.isFinite(item[0]) &&
35
- typeof item[1] === 'number' &&
36
- Number.isFinite(item[1]),
37
- )
41
+ function isPointArray(v: unknown): v is ScatterplotItem[] {
42
+ return Array.isArray(v) && v.every(isScatterplotDatum)
38
43
  }
39
44
 
40
45
  /**
41
- * `LockSelection` filter for Scatterplot. Data is `[number, number][][]` —
46
+ * `LockSelection` filter for Scatterplot. Data is `ScatterplotItem[][]` —
42
47
  * points have no `name`, so selection (and locking) is keyed on a synthetic
43
48
  * `${seriesIndex}:${dataIndex}` string (same format `useChartSelection`'s
44
49
  * `keyFromClick`/`keysFromBrush` produce for this widget). Unlike Histogram,
@@ -53,7 +58,7 @@ export function filterScatterplotByLockedPoints(
53
58
  if (!Array.isArray(input)) return input
54
59
  const locked = new Set<string>(lockedItems.map(String))
55
60
  return input.map((series: unknown, seriesIndex: number): unknown => {
56
- if (!isXyTupleArray(series)) return series
61
+ if (!isPointArray(series)) return series
57
62
  return series.filter((_, dataIndex) =>
58
63
  locked.has(`${seriesIndex}:${dataIndex}`),
59
64
  )
@@ -5,8 +5,31 @@ import type { WidgetSeries } from '../types'
5
5
  /** A single point — `[x, y]` tuple. */
6
6
  export type ScatterplotDatum = readonly [number, number]
7
7
 
8
- /** Scatterplot widget data — array of series, each a list of `[x, y]` tuples. */
9
- export type ScatterplotWidgetData = readonly (readonly ScatterplotDatum[])[]
8
+ /**
9
+ * Object form of a point, to give it its own colour. `color` (CSS colour or
10
+ * theme palette path such as `'primary.main'`) wins over the template's
11
+ * `itemStyle.color` and the series colour; selection dimming still applies.
12
+ */
13
+ export interface ScatterplotPoint {
14
+ x: number
15
+ y: number
16
+ color?: string
17
+ }
18
+
19
+ /** Any accepted point: an `[x, y]` tuple or a {@link ScatterplotPoint}. */
20
+ export type ScatterplotItem = ScatterplotDatum | ScatterplotPoint
21
+
22
+ /**
23
+ * Scatterplot widget data — array of series, each a list of points.
24
+ *
25
+ * Defaults to `[x, y]` tuples so existing code that destructures points
26
+ * keeps type-checking; use `ScatterplotWidgetData<ScatterplotItem>` to also
27
+ * accept `{ x, y, color? }` points. Every scatterplot API (option factory,
28
+ * transforms, download) accepts both forms.
29
+ */
30
+ export type ScatterplotWidgetData<
31
+ P extends ScatterplotItem = ScatterplotDatum,
32
+ > = readonly (readonly P[])[]
10
33
 
11
34
  /** Inputs to the structural-only {@link scatterplotOptions} builder. */
12
35
  export interface ScatterplotOptionsInput {