@carto/ps-react-ui 5.3.0 → 5.5.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 (92) hide show
  1. package/dist/{actions-Dhlz_adK.js → actions-CwjqEm8Y.js} +56 -46
  2. package/dist/actions-CwjqEm8Y.js.map +1 -0
  3. package/dist/category-C6myUFUJ.js +936 -0
  4. package/dist/category-C6myUFUJ.js.map +1 -0
  5. package/dist/item-color-ZYxH3NM5.js +20 -0
  6. package/dist/item-color-ZYxH3NM5.js.map +1 -0
  7. package/dist/types/widgets-v2/bar/types.d.ts +6 -0
  8. package/dist/types/widgets-v2/category/category-ui.d.ts +10 -2
  9. package/dist/types/widgets-v2/category/category.d.ts +7 -1
  10. package/dist/types/widgets-v2/category/components/category-bar-stacked.d.ts +8 -1
  11. package/dist/types/widgets-v2/category/components/category-bar.d.ts +24 -2
  12. package/dist/types/widgets-v2/category/components/category-row-multi.d.ts +3 -1
  13. package/dist/types/widgets-v2/category/components/category-row-single.d.ts +3 -1
  14. package/dist/types/widgets-v2/category/components/category-row-stacked.d.ts +10 -1
  15. package/dist/types/widgets-v2/category/style.d.ts +33 -6
  16. package/dist/types/widgets-v2/histogram/bins.d.ts +7 -0
  17. package/dist/types/widgets-v2/histogram/download.d.ts +2 -2
  18. package/dist/types/widgets-v2/histogram/index.d.ts +2 -1
  19. package/dist/types/widgets-v2/histogram/options.d.ts +1 -1
  20. package/dist/types/widgets-v2/histogram/transforms.d.ts +5 -4
  21. package/dist/types/widgets-v2/histogram/types.d.ts +22 -5
  22. package/dist/types/widgets-v2/index.d.ts +1 -1
  23. package/dist/types/widgets-v2/pie/types.d.ts +6 -0
  24. package/dist/types/widgets-v2/scatterplot/download.d.ts +2 -2
  25. package/dist/types/widgets-v2/scatterplot/index.d.ts +2 -1
  26. package/dist/types/widgets-v2/scatterplot/points.d.ts +7 -0
  27. package/dist/types/widgets-v2/scatterplot/transforms.d.ts +4 -3
  28. package/dist/types/widgets-v2/scatterplot/types.d.ts +21 -2
  29. package/dist/types/widgets-v2/timeseries/types.d.ts +6 -0
  30. package/dist/types/widgets-v2/utils/item-color.d.ts +26 -0
  31. package/dist/widgets-v2/actions.js +1 -1
  32. package/dist/widgets-v2/bar.js +66 -57
  33. package/dist/widgets-v2/bar.js.map +1 -1
  34. package/dist/widgets-v2/category.js +1 -1
  35. package/dist/widgets-v2/histogram.js +103 -71
  36. package/dist/widgets-v2/histogram.js.map +1 -1
  37. package/dist/widgets-v2/pie.js +98 -78
  38. package/dist/widgets-v2/pie.js.map +1 -1
  39. package/dist/widgets-v2/scatterplot.js +119 -79
  40. package/dist/widgets-v2/scatterplot.js.map +1 -1
  41. package/dist/widgets-v2/timeseries.js +70 -61
  42. package/dist/widgets-v2/timeseries.js.map +1 -1
  43. package/dist/widgets-v2.js +2 -2
  44. package/dist/widgets-v2.js.map +1 -1
  45. package/package.json +3 -3
  46. package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +77 -13
  47. package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +48 -5
  48. package/src/widgets-v2/bar/options.test.ts +106 -0
  49. package/src/widgets-v2/bar/options.ts +25 -14
  50. package/src/widgets-v2/bar/types.ts +6 -0
  51. package/src/widgets-v2/category/category-ui.test.tsx +139 -0
  52. package/src/widgets-v2/category/category-ui.tsx +64 -11
  53. package/src/widgets-v2/category/category.test.tsx +67 -0
  54. package/src/widgets-v2/category/category.tsx +32 -15
  55. package/src/widgets-v2/category/components/category-bar-stacked.test.tsx +18 -0
  56. package/src/widgets-v2/category/components/category-bar-stacked.tsx +42 -29
  57. package/src/widgets-v2/category/components/category-bar.test.tsx +66 -0
  58. package/src/widgets-v2/category/components/category-bar.tsx +65 -9
  59. package/src/widgets-v2/category/components/category-row-multi.tsx +21 -0
  60. package/src/widgets-v2/category/components/category-row-single.tsx +4 -0
  61. package/src/widgets-v2/category/components/category-row-stacked.tsx +24 -1
  62. package/src/widgets-v2/category/style.ts +58 -14
  63. package/src/widgets-v2/histogram/bins.ts +22 -0
  64. package/src/widgets-v2/histogram/download.test.ts +10 -0
  65. package/src/widgets-v2/histogram/download.ts +7 -3
  66. package/src/widgets-v2/histogram/index.ts +3 -0
  67. package/src/widgets-v2/histogram/options.test.ts +62 -0
  68. package/src/widgets-v2/histogram/options.ts +38 -17
  69. package/src/widgets-v2/histogram/transforms.test.ts +21 -7
  70. package/src/widgets-v2/histogram/transforms.ts +26 -12
  71. package/src/widgets-v2/histogram/types.ts +25 -5
  72. package/src/widgets-v2/index.ts +6 -1
  73. package/src/widgets-v2/pie/options.test.ts +91 -0
  74. package/src/widgets-v2/pie/options.ts +50 -20
  75. package/src/widgets-v2/pie/types.ts +6 -0
  76. package/src/widgets-v2/scatterplot/download.test.ts +9 -0
  77. package/src/widgets-v2/scatterplot/download.ts +5 -3
  78. package/src/widgets-v2/scatterplot/index.ts +3 -0
  79. package/src/widgets-v2/scatterplot/options.test.ts +78 -0
  80. package/src/widgets-v2/scatterplot/options.ts +35 -11
  81. package/src/widgets-v2/scatterplot/points.ts +35 -0
  82. package/src/widgets-v2/scatterplot/transforms.test.ts +19 -0
  83. package/src/widgets-v2/scatterplot/transforms.ts +23 -18
  84. package/src/widgets-v2/scatterplot/types.ts +25 -2
  85. package/src/widgets-v2/timeseries/options.test.ts +65 -0
  86. package/src/widgets-v2/timeseries/options.ts +31 -14
  87. package/src/widgets-v2/timeseries/types.ts +6 -0
  88. package/src/widgets-v2/utils/item-color.test.ts +74 -0
  89. package/src/widgets-v2/utils/item-color.ts +55 -0
  90. package/dist/actions-Dhlz_adK.js.map +0 -1
  91. package/dist/category-D01ruG-l.js +0 -842
  92. package/dist/category-D01ruG-l.js.map +0 -1
@@ -28,17 +28,19 @@ const bar: Record<CategorySize, SxProps<Theme>> = {
28
28
  const barFill: Record<CategorySize, SxProps<Theme>> = {
29
29
  small: {
30
30
  position: 'absolute',
31
- inset: '0 auto 0 0',
31
+ top: 0,
32
+ bottom: 0,
32
33
  borderRadius: 1,
33
34
  transition:
34
- 'width 300ms ease-in-out, background-color 150ms ease, filter 150ms ease',
35
+ 'left 300ms ease-in-out, width 300ms ease-in-out, background-color 150ms ease, filter 150ms ease',
35
36
  },
36
37
  medium: {
37
38
  position: 'absolute',
38
- inset: '0 auto 0 0',
39
+ top: 0,
40
+ bottom: 0,
39
41
  borderRadius: 0.25,
40
42
  transition:
41
- 'width 300ms ease-in-out, background-color 150ms ease, filter 150ms ease',
43
+ 'left 300ms ease-in-out, width 300ms ease-in-out, background-color 150ms ease, filter 150ms ease',
42
44
  },
43
45
  }
44
46
 
@@ -71,13 +73,47 @@ export const styles = {
71
73
  position: 'relative',
72
74
  width: '100%',
73
75
  },
74
- // Flat list of category rows. Holds both single + multi rows.
76
+ // Flat list of category rows. A grid rather than a flex column so the
77
+ // multi-series rows can share one value column through `subgrid`: with
78
+ // every bar track the same width, the zero ticks line up across rows.
75
79
  list: {
76
- display: 'flex',
77
- flexDirection: 'column',
80
+ position: 'relative',
81
+ display: 'grid',
82
+ gridTemplateColumns: 'minmax(0, 1fr)',
78
83
  gap: 1,
79
84
  py: 0.5,
80
85
  },
86
+ // Layered on `list` for unstacked multi-series: bars | values.
87
+ listMulti: {
88
+ gridTemplateColumns: 'minmax(0, 1fr) max-content',
89
+ },
90
+ // Zero tick for signed data: one per bar track, drawn outside the track's
91
+ // `overflow: hidden` so it can stand 6px proud of it on both sides.
92
+ // Tracks share a width (see `listMulti`), so the ticks line up by rows.
93
+ barWrap: {
94
+ position: 'relative',
95
+ },
96
+ // Multi-series rows draw one zero line through every series bar instead
97
+ // of a tick per bar: this box is the row's bar column (grid area set
98
+ // inline) and holds a `zeroTick`, so the line stays out of the label.
99
+ multiZeroArea: {
100
+ position: 'absolute',
101
+ inset: 0,
102
+ gridColumn: '1 / 2',
103
+ pointerEvents: 'none',
104
+ },
105
+ zeroTick: {
106
+ position: 'absolute',
107
+ top: -6,
108
+ bottom: -6,
109
+ width: '1px',
110
+ // Centred on the zero so both halves of the bar meet it evenly. No
111
+ // z-index: it comes before the track in the DOM, so the bar paints over
112
+ // it and only the overhang shows.
113
+ transform: 'translateX(-50%)',
114
+ bgcolor: 'text.disabled',
115
+ pointerEvents: 'none',
116
+ },
81
117
  // Layered on `list` only in scroll mode (`maxItems === 0`): right padding
82
118
  // so the bars + right-aligned values clear the scrollbar. Right side only
83
119
  // — the left edge stays at 0 so content stays aligned with the capped
@@ -91,6 +127,7 @@ export const styles = {
91
127
  // bar fills the row width below. Mirrors `rowMulti` for visual
92
128
  // consistency.
93
129
  rowSingle: {
130
+ gridColumn: '1 / -1',
94
131
  display: 'flex',
95
132
  flexDirection: 'column',
96
133
  gap: 0.5,
@@ -124,10 +161,14 @@ export const styles = {
124
161
  // Flex column: label fills the top row; bar+value pairs stack below.
125
162
  // Bars get full track width — better legibility for N>=2 series than a
126
163
  // shared grid would give.
164
+ // Spans the list and adopts its columns, so every row's value column is
165
+ // as wide as the widest value in the list.
127
166
  rowMulti: {
128
- display: 'flex',
129
- flexDirection: 'column',
130
- gap: 0.5,
167
+ position: 'relative',
168
+ gridColumn: '1 / -1',
169
+ display: 'grid',
170
+ gridTemplateColumns: 'subgrid',
171
+ rowGap: 0.5,
131
172
  // py: 0.5,
132
173
  // px: 0.5,
133
174
  cursor: 'pointer',
@@ -155,6 +196,7 @@ export const styles = {
155
196
  fontWeight: 'medium',
156
197
  },
157
198
  multiName: {
199
+ gridColumn: '1 / -1',
158
200
  overflow: 'hidden',
159
201
  whiteSpace: 'nowrap',
160
202
  textOverflow: 'ellipsis',
@@ -171,6 +213,7 @@ export const styles = {
171
213
  // minWidth here is also harmless in that context.
172
214
  minWidth: 48,
173
215
  textAlign: 'right',
216
+ alignSelf: 'center',
174
217
  },
175
218
 
176
219
  // ── Bar (track + fill) ────────────────────────────────────────────
@@ -184,13 +227,12 @@ export const styles = {
184
227
 
185
228
  // ── Multi-series internals ────────────────────────────────────────
186
229
  // One [bar | value] flex row per series, inside `rowMulti`'s column.
230
+ // `contents` hands the bar and the value to `rowMulti`'s subgrid cells.
187
231
  multiBarRow: {
188
- display: 'flex',
189
- alignItems: 'center',
190
- gap: 1,
232
+ display: 'contents',
191
233
  },
192
234
  multiBarContainer: {
193
- flex: 1,
235
+ alignSelf: 'center',
194
236
  },
195
237
 
196
238
  // ── Stacked-mode row ──────────────────────────────────────────────
@@ -198,6 +240,7 @@ export const styles = {
198
240
  // focus ring) but stack three children vertically: header line, bar,
199
241
  // breakdown line.
200
242
  rowStacked: {
243
+ gridColumn: '1 / -1',
201
244
  display: 'flex',
202
245
  flexDirection: 'column',
203
246
  gap: 0.5,
@@ -285,6 +328,7 @@ export const styles = {
285
328
  // (no parentheses). Only the label is the click target when interactive;
286
329
  // the count is always static text beside it.
287
330
  otherRow: {
331
+ gridColumn: '1 / -1',
288
332
  display: 'flex',
289
333
  alignItems: 'center',
290
334
  gap: 1,
@@ -0,0 +1,22 @@
1
+ import type { HistogramBin } from './types'
2
+
3
+ /** Count of a {@link HistogramBin}, whichever form it takes. */
4
+ export function getHistogramBinValue(bin: HistogramBin): number {
5
+ return typeof bin === 'number' ? bin : bin.value
6
+ }
7
+
8
+ /** Whether `v` is a valid {@link HistogramBin} with a finite count. */
9
+ export function isHistogramBin(v: unknown): v is HistogramBin {
10
+ if (typeof v === 'number') return Number.isFinite(v)
11
+ if (v == null || typeof v !== 'object') return false
12
+ const value = (v as { value?: unknown }).value
13
+ return typeof value === 'number' && Number.isFinite(value)
14
+ }
15
+
16
+ /** Same bin with its count replaced; keeps the object form (and `color`). */
17
+ export function withHistogramBinValue(
18
+ bin: HistogramBin,
19
+ value: number,
20
+ ): HistogramBin {
21
+ return typeof bin === 'number' ? value : { ...bin, value }
22
+ }
@@ -61,6 +61,16 @@ describe('createHistogramDownloadConfig', () => {
61
61
  )
62
62
  })
63
63
 
64
+ it('CSV writes the count of object bins (colour is not exported)', async () => {
65
+ const items = createHistogramDownloadConfig({
66
+ filename: 'h',
67
+ getData: () => [[{ value: 1, color: '#f00' }, 2, { value: 3 }]],
68
+ getTicks: () => ticks,
69
+ })
70
+ await items.find((i) => i.id === 'csv')!.resolve({ id: null })
71
+ expect(csvText).toBe('bin_low,bin_high,series_1\n0,10,1\n10,20,2\n20,30,3')
72
+ })
73
+
64
74
  it('CSV honours supplied seriesNames', async () => {
65
75
  const items = createHistogramDownloadConfig({
66
76
  filename: 'h',
@@ -4,7 +4,8 @@ import {
4
4
  type DownloadContext,
5
5
  type DownloadItem,
6
6
  } from '../actions/download'
7
- import type { HistogramWidgetData } from './types'
7
+ import { getHistogramBinValue } from './bins'
8
+ import type { HistogramBin, HistogramWidgetData } from './types'
8
9
 
9
10
  /**
10
11
  * Download menu items for histograms. Always includes a CSV item with
@@ -14,7 +15,7 @@ import type { HistogramWidgetData } from './types'
14
15
  */
15
16
  export function createHistogramDownloadConfig(args: {
16
17
  filename: string
17
- getData: (ctx: DownloadContext) => HistogramWidgetData
18
+ getData: (ctx: DownloadContext) => HistogramWidgetData<HistogramBin>
18
19
  getTicks: () => readonly number[]
19
20
  seriesNames?: readonly string[]
20
21
  getCaptureEl?: (ctx: DownloadContext) => HTMLElement | null
@@ -46,7 +47,10 @@ export function createHistogramDownloadConfig(args: {
46
47
  const rows: unknown[][] = [header]
47
48
  for (let bin = 0; bin < Math.max(0, ticks.length - 1); bin++) {
48
49
  const row: unknown[] = [ticks[bin], ticks[bin + 1]]
49
- for (let s = 0; s < seriesCount; s++) row.push(data[s]?.[bin] ?? 0)
50
+ for (let s = 0; s < seriesCount; s++) {
51
+ const value = data[s]?.[bin]
52
+ row.push(value == null ? 0 : getHistogramBinValue(value))
53
+ }
50
54
  rows.push(row)
51
55
  }
52
56
  return rows
@@ -5,7 +5,10 @@ export {
5
5
  toRelativeHistogramData,
6
6
  filterHistogramByLockedBins,
7
7
  } from './transforms'
8
+ export { getHistogramBinValue } from './bins'
8
9
  export type {
10
+ HistogramBin,
11
+ HistogramBinObject,
9
12
  HistogramWidgetData,
10
13
  HistogramOptionsInput,
11
14
  HistogramOptionFactoryInput,
@@ -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 { createHistogramOptionFactory, histogramOptions } from './options'
4
5
 
@@ -409,3 +410,64 @@ describe('createHistogramOptionFactory (data → dataset merger)', () => {
409
410
  })
410
411
  })
411
412
  })
413
+
414
+ describe('createHistogramOptionFactory — per-item colour', () => {
415
+ interface Out {
416
+ dataset: { source: (string | number)[][] }[]
417
+ series: { itemStyle: { color: (p: unknown) => string } }[]
418
+ }
419
+ const ticks = [0, 10, 20, 30]
420
+ const dim = (c: string) => echarts.color.modifyAlpha(c, 0.15)
421
+
422
+ it('accepts object bins: dataset carries the counts', () => {
423
+ const merge = createHistogramOptionFactory({ theme, ticks })
424
+ const out = merge({}, [[{ value: 1, color: '#00ff00' }, 2, { value: 3 }]])
425
+ expect((out as Out).dataset[0]!.source.map(([, v]) => v)).toEqual([1, 2, 3])
426
+ })
427
+
428
+ it('bin colour wins over the series colour; template next', () => {
429
+ const merge = createHistogramOptionFactory({
430
+ theme,
431
+ ticks,
432
+ series: [{ name: 'S', color: '#0000ff' }],
433
+ selection: [0, 1],
434
+ })
435
+ const out = merge(
436
+ { series: [{ type: 'bar', itemStyle: { color: '#123456' } }] },
437
+ [[{ value: 1, color: '#00ff00' }, 2, { value: 3 }]],
438
+ ) as Out
439
+ const colorFn = out.series[0]!.itemStyle.color
440
+ expect(colorFn({ dataIndex: 0, color: '#0000ff' })).toBe('#00ff00')
441
+ expect(colorFn({ dataIndex: 1, color: '#0000ff' })).toBe('#123456')
442
+ expect(colorFn({ dataIndex: 2, color: '#0000ff' })).toBe(dim('#123456'))
443
+ })
444
+
445
+ it('calls a function template itemStyle.color via optionsOverride', () => {
446
+ const factory = createHistogramOptionFactory({
447
+ theme,
448
+ ticks,
449
+ optionsOverride: {
450
+ series: [
451
+ {
452
+ type: 'bar',
453
+ itemStyle: {
454
+ color: (p: { dataIndex: number }) => `#c${p.dataIndex}`,
455
+ },
456
+ },
457
+ ],
458
+ },
459
+ })
460
+ const out = factory(factory(undefined, undefined), [[1, 2, 3]]) as Out
461
+ expect(out.series[0]!.itemStyle.color({ dataIndex: 2, color: '#x' })).toBe(
462
+ '#c2',
463
+ )
464
+ })
465
+
466
+ it('keeps the series / palette colour for plain number bins', () => {
467
+ const merge = createHistogramOptionFactory({ theme, ticks, selection: [1] })
468
+ const out = merge({}, [[1, 2, 3]]) as Out
469
+ const colorFn = out.series[0]!.itemStyle.color
470
+ expect(colorFn({ dataIndex: 0, color: '#abcdef' })).toBe(dim('#abcdef'))
471
+ expect(colorFn({ dataIndex: 1, color: '#abcdef' })).toBe('#abcdef')
472
+ })
473
+ })
@@ -12,11 +12,14 @@ 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 { getHistogramBinValue } from './bins'
15
17
  import { positionDataZoomForLegend } from '../utils/data-zoom-layout'
16
18
  import type {
17
19
  HistogramEChartsOption,
18
20
  HistogramOptionFactoryInput,
19
21
  HistogramOptionsInput,
22
+ HistogramBin,
20
23
  HistogramWidgetData,
21
24
  } from './types'
22
25
 
@@ -147,7 +150,7 @@ export function histogramOptions({
147
150
  * the consumer-supplied `optionsOverride`. Called once by Provider to
148
151
  * seed `rawOptions` in the store.
149
152
  * - **Merge phase** (`option != null`) — fuses post-pipeline `state.data`
150
- * (`HistogramWidgetData` — `number[][]`) into the option via the
153
+ * (`HistogramWidgetData` — `HistogramBin[][]`) into the option via the
151
154
  * dataset API. Each series gets its own dataset of `[binLabel, count]`
152
155
  * pairs derived from `ticks`; series reference their dataset by
153
156
  * `datasetIndex` and are encoded by column position. Reactive
@@ -199,8 +202,8 @@ export function createHistogramOptionFactory(
199
202
  : structural
200
203
  }
201
204
 
202
- const seriesArr: HistogramWidgetData = Array.isArray(data)
203
- ? (data as HistogramWidgetData)
205
+ const seriesArr: HistogramWidgetData<HistogramBin> = Array.isArray(data)
206
+ ? (data as HistogramWidgetData<HistogramBin>)
204
207
  : []
205
208
  if (seriesArr.length === 0 || binLabels.length === 0) {
206
209
  return { ...option, dataset: [], series: [] }
@@ -261,14 +264,28 @@ export function createHistogramOptionFactory(
261
264
  // and bins would stay dimmed forever after an external clear. Always
262
265
  // emitting lets normal merge swap the callback in place, no
263
266
  // `replaceMerge` and no entry-animation flash on selection on/off.
264
- const dimItemStyle = {
265
- color: (params: CallbackDataParams) => {
266
- const base = params.color as string
267
- if (!selectionSet) return base
268
- return selectionSet.has(params.dataIndex)
269
- ? base
270
- : echarts.color.modifyAlpha(base, 0.15)
271
- },
267
+ //
268
+ // The base colour is the bin's own `color` (object bins), else the
269
+ // template's `itemStyle.color`, else the series colour / palette.
270
+ const makeDimItemStyle = (
271
+ datums: readonly unknown[],
272
+ template: unknown,
273
+ ) => {
274
+ const baseColor = createItemColorResolver({
275
+ theme,
276
+ datums,
277
+ template,
278
+ fallback: (params) => params.color as string,
279
+ })
280
+ return {
281
+ color: (params: CallbackDataParams) => {
282
+ const base = baseColor(params)
283
+ if (!selectionSet) return base
284
+ return selectionSet.has(params.dataIndex)
285
+ ? base
286
+ : echarts.color.modifyAlpha(base, 0.15)
287
+ },
288
+ }
272
289
  }
273
290
 
274
291
  // A consumer's `optionsOverride.xAxis` may legitimately be an array
@@ -306,12 +323,16 @@ export function createHistogramOptionFactory(
306
323
  return {
307
324
  ...option,
308
325
  xAxis: nextXAxis,
309
- dataset: seriesArr.map((counts) => ({
310
- source: binLabels.map(
311
- (label, i) => [label, counts[i] ?? 0] as [string, number],
312
- ),
326
+ dataset: seriesArr.map((bins) => ({
327
+ source: binLabels.map((label, i) => {
328
+ const bin = bins[i]
329
+ return [label, bin == null ? 0 : getHistogramBinValue(bin)] as [
330
+ string,
331
+ number,
332
+ ]
333
+ }),
313
334
  })),
314
- series: seriesArr.map((_, i) => {
335
+ series: seriesArr.map((bins, i) => {
315
336
  const template =
316
337
  (seriesTemplates[i] as object | undefined) ??
317
338
  (broadcastTemplate as object)
@@ -324,7 +345,7 @@ export function createHistogramOptionFactory(
324
345
  encode: { x: 0, y: 1 },
325
346
  barCategoryGap: '0%',
326
347
  emphasis: { focus: 'series' },
327
- itemStyle: dimItemStyle,
348
+ itemStyle: makeDimItemStyle(bins, template),
328
349
  ...(overrideColor ? { color: overrideColor } : {}),
329
350
  }
330
351
  }),
@@ -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 {
@@ -175,7 +175,12 @@ export type {
175
175
  export type { MarkdownUIProps, MarkdownWidgetData } from './markdown'
176
176
  export type { PieDatum, PieWidgetData } from './pie'
177
177
  export type { TimeseriesDatum, TimeseriesWidgetData } from './timeseries'
178
- export type { ScatterplotDatum, ScatterplotWidgetData } from './scatterplot'
178
+ export type {
179
+ ScatterplotDatum,
180
+ ScatterplotPoint,
181
+ ScatterplotItem,
182
+ ScatterplotWidgetData,
183
+ } from './scatterplot'
179
184
  export type {
180
185
  SpreadUIProps,
181
186
  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
+ })