@carto/ps-react-ui 4.25.0 → 4.26.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 (67) hide show
  1. package/dist/{actions-B1ss030_.js → actions-DXfQMrll.js} +56 -46
  2. package/dist/actions-DXfQMrll.js.map +1 -0
  3. package/dist/item-color-C1cu6EJd.js +20 -0
  4. package/dist/item-color-C1cu6EJd.js.map +1 -0
  5. package/dist/types/widgets-v2/bar/types.d.ts +6 -0
  6. package/dist/types/widgets-v2/histogram/bins.d.ts +7 -0
  7. package/dist/types/widgets-v2/histogram/download.d.ts +2 -2
  8. package/dist/types/widgets-v2/histogram/index.d.ts +2 -1
  9. package/dist/types/widgets-v2/histogram/options.d.ts +1 -1
  10. package/dist/types/widgets-v2/histogram/transforms.d.ts +5 -4
  11. package/dist/types/widgets-v2/histogram/types.d.ts +22 -5
  12. package/dist/types/widgets-v2/index.d.ts +1 -1
  13. package/dist/types/widgets-v2/pie/types.d.ts +6 -0
  14. package/dist/types/widgets-v2/scatterplot/download.d.ts +2 -2
  15. package/dist/types/widgets-v2/scatterplot/index.d.ts +2 -1
  16. package/dist/types/widgets-v2/scatterplot/points.d.ts +7 -0
  17. package/dist/types/widgets-v2/scatterplot/transforms.d.ts +4 -3
  18. package/dist/types/widgets-v2/scatterplot/types.d.ts +21 -2
  19. package/dist/types/widgets-v2/timeseries/types.d.ts +6 -0
  20. package/dist/types/widgets-v2/utils/item-color.d.ts +26 -0
  21. package/dist/widgets-v2/actions.js +1 -1
  22. package/dist/widgets-v2/bar.js +66 -57
  23. package/dist/widgets-v2/bar.js.map +1 -1
  24. package/dist/widgets-v2/histogram.js +103 -71
  25. package/dist/widgets-v2/histogram.js.map +1 -1
  26. package/dist/widgets-v2/pie.js +98 -78
  27. package/dist/widgets-v2/pie.js.map +1 -1
  28. package/dist/widgets-v2/scatterplot.js +119 -79
  29. package/dist/widgets-v2/scatterplot.js.map +1 -1
  30. package/dist/widgets-v2/timeseries.js +70 -61
  31. package/dist/widgets-v2/timeseries.js.map +1 -1
  32. package/dist/widgets-v2.js +1 -1
  33. package/dist/widgets-v2.js.map +1 -1
  34. package/package.json +3 -3
  35. package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +77 -13
  36. package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +48 -5
  37. package/src/widgets-v2/bar/options.test.ts +106 -0
  38. package/src/widgets-v2/bar/options.ts +25 -14
  39. package/src/widgets-v2/bar/types.ts +6 -0
  40. package/src/widgets-v2/histogram/bins.ts +22 -0
  41. package/src/widgets-v2/histogram/download.test.ts +10 -0
  42. package/src/widgets-v2/histogram/download.ts +7 -3
  43. package/src/widgets-v2/histogram/index.ts +3 -0
  44. package/src/widgets-v2/histogram/options.test.ts +62 -0
  45. package/src/widgets-v2/histogram/options.ts +38 -17
  46. package/src/widgets-v2/histogram/transforms.test.ts +21 -7
  47. package/src/widgets-v2/histogram/transforms.ts +26 -12
  48. package/src/widgets-v2/histogram/types.ts +25 -5
  49. package/src/widgets-v2/index.ts +6 -1
  50. package/src/widgets-v2/pie/options.test.ts +91 -0
  51. package/src/widgets-v2/pie/options.ts +50 -20
  52. package/src/widgets-v2/pie/types.ts +6 -0
  53. package/src/widgets-v2/scatterplot/download.test.ts +9 -0
  54. package/src/widgets-v2/scatterplot/download.ts +5 -3
  55. package/src/widgets-v2/scatterplot/index.ts +3 -0
  56. package/src/widgets-v2/scatterplot/options.test.ts +78 -0
  57. package/src/widgets-v2/scatterplot/options.ts +35 -11
  58. package/src/widgets-v2/scatterplot/points.ts +35 -0
  59. package/src/widgets-v2/scatterplot/transforms.test.ts +19 -0
  60. package/src/widgets-v2/scatterplot/transforms.ts +23 -18
  61. package/src/widgets-v2/scatterplot/types.ts +25 -2
  62. package/src/widgets-v2/timeseries/options.test.ts +65 -0
  63. package/src/widgets-v2/timeseries/options.ts +32 -17
  64. package/src/widgets-v2/timeseries/types.ts +6 -0
  65. package/src/widgets-v2/utils/item-color.test.ts +74 -0
  66. package/src/widgets-v2/utils/item-color.ts +55 -0
  67. package/dist/actions-B1ss030_.js.map +0 -1
@@ -130,26 +130,90 @@ describe('<FullScreen.Slot> — the dialog runs on its own store', () => {
130
130
  // Legend / dataZoom / brush live in the chart instance, not the store.
131
131
  // The Slot moves them through the instance registry, so Widget.Echart
132
132
  // doesn't need to know about fullscreen.
133
+ const fakeChart = (option: object, brushAreas?: unknown[]) =>
134
+ ({
135
+ getOption: vi.fn(() => option),
136
+ setOption: vi.fn(),
137
+ dispatchAction: vi.fn(),
138
+ getModel: () => ({
139
+ getComponent: (type: string) =>
140
+ type === 'brush' ? { areas: brushAreas } : undefined,
141
+ }),
142
+ isDisposed: () => false,
143
+ }) as unknown as ECharts & {
144
+ setOption: ReturnType<typeof vi.fn>
145
+ dispatchAction: ReturnType<typeof vi.fn>
146
+ }
147
+
133
148
  it('starts the dialog chart from the card chart and hands it back on close', async () => {
134
- const fakeChart = (option: object) =>
135
- ({
136
- getOption: vi.fn(() => option),
137
- setOption: vi.fn(),
138
- dispatchAction: vi.fn(),
139
- isDisposed: () => false,
140
- }) as unknown as ECharts & {
141
- setOption: ReturnType<typeof vi.fn>
142
- }
143
- const card = fakeChart({ from: 'card' })
144
- const dialog = fakeChart({ from: 'dialog' })
149
+ const cardOption = {
150
+ series: [{ type: 'bar' }],
151
+ xAxis: [{ type: 'category' }],
152
+ legend: [{ selected: { a: true, b: false } }],
153
+ dataZoom: [{ start: 10, end: 60 }],
154
+ }
155
+ const card = fakeChart(cardOption)
156
+ const dialog = fakeChart({
157
+ legend: [{ selected: { a: false, b: true } }],
158
+ dataZoom: [{ start: 20, end: 80 }],
159
+ })
160
+ render(<Widget />)
161
+ act(() => setEchartInstance('w', card))
162
+
163
+ // The empty dialog chart takes the whole option, so the view state has
164
+ // the card's axes and series to land on.
165
+ fireEvent.click(screen.getByLabelText('OpenT'))
166
+ act(() => setEchartInstance(dialogId, dialog))
167
+ expect(dialog.setOption).toHaveBeenCalledWith(cardOption)
168
+
169
+ await close()
170
+ expect(card.setOption).toHaveBeenCalledWith({
171
+ legend: [{ selected: { a: false, b: true } }],
172
+ dataZoom: [{ start: 20, end: 80 }],
173
+ })
174
+ })
175
+
176
+ // The dialog often renders with its own styling (bigger axis labels,
177
+ // value labels on the series). Copying the whole option back would leave
178
+ // that styling on the card after closing.
179
+ it('hands back only the view state, not the dialog styling', async () => {
180
+ const card = fakeChart({ series: [{ type: 'bar' }] })
181
+ const dialog = fakeChart({
182
+ series: [{ type: 'bar', label: { show: true, rotate: 90 } }],
183
+ xAxis: [{ axisLabel: { fontSize: 13 } }],
184
+ dataZoom: [{ start: 0, end: 50, startValue: 3, endValue: 9 }],
185
+ })
186
+ render(<Widget />)
187
+ act(() => setEchartInstance('w', card))
188
+ fireEvent.click(screen.getByLabelText('OpenT'))
189
+ act(() => setEchartInstance(dialogId, dialog))
190
+
191
+ await close()
192
+ expect(card.setOption).toHaveBeenCalledWith({
193
+ dataZoom: [{ start: 0, end: 50, startValue: 3, endValue: 9 }],
194
+ })
195
+ })
196
+
197
+ // Brush areas aren't in getOption(); they travel through the brush action.
198
+ it('starts the dialog chart with the card brush areas and hands them back on close', async () => {
199
+ const cardAreas = [{ brushType: 'lineX', coordRange: [1, 3] }]
200
+ const dialogAreas = [{ brushType: 'lineX', coordRange: [4, 7] }]
201
+ const card = fakeChart({}, cardAreas)
202
+ const dialog = fakeChart({}, dialogAreas)
145
203
  render(<Widget />)
146
204
  act(() => setEchartInstance('w', card))
147
205
 
148
206
  fireEvent.click(screen.getByLabelText('OpenT'))
149
207
  act(() => setEchartInstance(dialogId, dialog))
150
- expect(dialog.setOption).toHaveBeenCalledWith({ from: 'card' })
208
+ expect(dialog.dispatchAction).toHaveBeenCalledWith({
209
+ type: 'brush',
210
+ areas: cardAreas,
211
+ })
151
212
 
152
213
  await close()
153
- expect(card.setOption).toHaveBeenCalledWith({ from: 'dialog' })
214
+ expect(card.dispatchAction).toHaveBeenCalledWith({
215
+ type: 'brush',
216
+ areas: dialogAreas,
217
+ })
154
218
  })
155
219
  })
@@ -315,15 +315,17 @@ function DialogStore({
315
315
  const unsubscribe = subscribeEchartInstance(dialogId, (chart) => {
316
316
  // Published from inside the chart's init, before its first
317
317
  // setOption, so the dialog's own option merges on top of the copy.
318
+ // The empty chart takes the card's whole option: the legend, dataZoom
319
+ // and brush areas need the card's components (axes, brush) to land on.
318
320
  const card = liveChart(sourceId)
319
- if (chart && card) copyChartState(card, chart)
321
+ if (chart && card) copyChartState(card, chart, card.getOption())
320
322
  dialogChart = chart
321
323
  })
322
324
  return () => {
323
325
  unsubscribe()
324
326
  const card = liveChart(sourceId)
325
327
  if (card && dialogChart && !dialogChart.isDisposed()) {
326
- copyChartState(dialogChart, card)
328
+ copyChartState(dialogChart, card, viewState(dialogChart))
327
329
  }
328
330
  }
329
331
  }, [dialogId, sourceId])
@@ -351,10 +353,15 @@ function liveChart(id: string): ECharts | null {
351
353
  /**
352
354
  * Put on `chart` what `peer` is showing: the legend selection, the dataZoom
353
355
  * window and any drawn brush areas, which live in the ECharts instance
354
- * rather than the widget store.
356
+ * rather than the widget store. `option` is what of `peer`'s option to merge
357
+ * into `chart`.
355
358
  */
356
- function copyChartState(peer: ECharts, chart: ECharts): void {
357
- chart.setOption(peer.getOption())
359
+ function copyChartState(
360
+ peer: ECharts,
361
+ chart: ECharts,
362
+ option: Parameters<ECharts['setOption']>[0],
363
+ ): void {
364
+ chart.setOption(option)
358
365
  // The pointer was over `peer`, not `chart`: drop the tooltip and axis
359
366
  // pointer that came along, or they'd stay drawn until the next hover.
360
367
  chart.dispatchAction({ type: 'hideTip' })
@@ -374,6 +381,42 @@ function copyChartState(peer: ECharts, chart: ECharts): void {
374
381
  if (areas?.length) chart.dispatchAction({ type: 'brush', areas })
375
382
  }
376
383
 
384
+ interface ViewStateOption {
385
+ // setOption takes an indexed option.
386
+ [key: string]: unknown
387
+ legend?: { selected?: Record<string, boolean> }[]
388
+ dataZoom?: Record<string, unknown>[]
389
+ }
390
+
391
+ const ZOOM_WINDOW_KEYS = ['start', 'end', 'startValue', 'endValue'] as const
392
+
393
+ /**
394
+ * The part of `peer`'s option that is user view state. Only this goes back
395
+ * to the card on close: the dialog's full option also carries its own
396
+ * styling (value labels, bigger axis text), and merging it into the card
397
+ * would leave that styling behind once the dialog closes.
398
+ */
399
+ function viewState(peer: ECharts): ViewStateOption {
400
+ const { legend, dataZoom } = peer.getOption() as ViewStateOption
401
+ const state: ViewStateOption = {}
402
+ if (legend?.some((l) => l.selected)) {
403
+ state.legend = legend.map((l) =>
404
+ l.selected ? { selected: l.selected } : {},
405
+ )
406
+ }
407
+ if (dataZoom?.length) {
408
+ state.dataZoom = dataZoom.map((zoom) =>
409
+ Object.fromEntries(
410
+ ZOOM_WINDOW_KEYS.filter((k) => zoom[k] !== undefined).map((k) => [
411
+ k,
412
+ zoom[k],
413
+ ]),
414
+ ),
415
+ )
416
+ }
417
+ return state
418
+ }
419
+
377
420
  /**
378
421
  * Merge a caller's `paperSx` over the default paper.
379
422
  *
@@ -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 { barOptions, createBarOptionFactory } from './options'
4
5
 
@@ -359,3 +360,108 @@ describe('createBarOptionFactory', () => {
359
360
  expect(html).toContain('[ITEM:A]')
360
361
  })
361
362
  })
363
+
364
+ describe('createBarOptionFactory — per-item colour', () => {
365
+ interface Out {
366
+ series: { itemStyle: { color: (p: unknown) => string } }[]
367
+ }
368
+ const data = [
369
+ [
370
+ { name: 'A', value: 1, color: '#00ff00' },
371
+ { name: 'B', value: -2 },
372
+ ],
373
+ ]
374
+ const dim = (c: string) => echarts.color.modifyAlpha(c, 0.15)
375
+
376
+ it('datum colour wins over the series colour', () => {
377
+ const merge = createBarOptionFactory({
378
+ theme,
379
+ series: [{ name: 'S', color: '#0000ff' }],
380
+ })
381
+ const out = merge({}, data) as Out
382
+ const colorFn = out.series[0]!.itemStyle.color
383
+ expect(colorFn({ dataIndex: 0, color: '#0000ff', name: 'A' })).toBe(
384
+ '#00ff00',
385
+ )
386
+ expect(colorFn({ dataIndex: 1, color: '#0000ff', name: 'B' })).toBe(
387
+ '#0000ff',
388
+ )
389
+ })
390
+
391
+ it('uses a string template itemStyle.color from optionsOverride', () => {
392
+ const factory = createBarOptionFactory({
393
+ theme,
394
+ series: [{ name: 'S', color: '#0000ff' }],
395
+ optionsOverride: {
396
+ series: [{ type: 'bar', itemStyle: { color: '#123456' } }],
397
+ },
398
+ })
399
+ const out = factory(factory(undefined, undefined), data) as Out
400
+ const colorFn = out.series[0]!.itemStyle.color
401
+ expect(colorFn({ dataIndex: 0, color: '#0000ff', name: 'A' })).toBe(
402
+ '#00ff00',
403
+ )
404
+ expect(colorFn({ dataIndex: 1, color: '#0000ff', name: 'B' })).toBe(
405
+ '#123456',
406
+ )
407
+ })
408
+
409
+ it('calls a function template itemStyle.color with the ECharts params', () => {
410
+ const merge = createBarOptionFactory({ theme })
411
+ const out = merge(
412
+ {
413
+ series: [
414
+ {
415
+ type: 'bar',
416
+ itemStyle: {
417
+ color: (p: { dataIndex: number }) =>
418
+ p.dataIndex === 1 ? '#ff0000' : '#00ff00',
419
+ },
420
+ },
421
+ ],
422
+ },
423
+ [
424
+ [
425
+ { name: 'A', value: 1 },
426
+ { name: 'B', value: -2 },
427
+ ],
428
+ ],
429
+ ) as Out
430
+ const colorFn = out.series[0]!.itemStyle.color
431
+ expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe('#00ff00')
432
+ expect(colorFn({ dataIndex: 1, color: '#abc', name: 'B' })).toBe('#ff0000')
433
+ })
434
+
435
+ it('resolves theme palette paths on the datum colour', () => {
436
+ const merge = createBarOptionFactory({ theme })
437
+ const out = merge({}, [[{ name: 'A', value: 1, color: 'primary.main' }]])
438
+ const colorFn = (out as Out).series[0]!.itemStyle.color
439
+ expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe(
440
+ theme.palette.primary.main,
441
+ )
442
+ })
443
+
444
+ it('dims both datum and template colours outside the selection', () => {
445
+ const merge = createBarOptionFactory({ theme, selection: ['Z'] })
446
+ const out = merge(
447
+ { series: [{ type: 'bar', itemStyle: { color: '#123456' } }] },
448
+ data,
449
+ ) as Out
450
+ const colorFn = out.series[0]!.itemStyle.color
451
+ expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe(
452
+ dim('#00ff00'),
453
+ )
454
+ expect(colorFn({ dataIndex: 1, color: '#abc', name: 'B' })).toBe(
455
+ dim('#123456'),
456
+ )
457
+ })
458
+
459
+ it('keeps the series / palette colour when no item colour is given', () => {
460
+ const merge = createBarOptionFactory({ theme, selection: ['Z'] })
461
+ const out = merge({}, [[{ name: 'A', value: 1 }]]) as Out
462
+ const colorFn = out.series[0]!.itemStyle.color
463
+ expect(colorFn({ dataIndex: 0, color: '#abcdef', name: 'A' })).toBe(
464
+ dim('#abcdef'),
465
+ )
466
+ })
467
+ })
@@ -12,6 +12,7 @@ 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'
15
16
  import { positionDataZoomForLegend } from '../utils/data-zoom-layout'
16
17
  import type {
17
18
  BarEChartsOption,
@@ -234,10 +235,12 @@ export function createBarOptionFactory(
234
235
  : baseBottom
235
236
 
236
237
  // When a selection is active, dim non-selected bars by routing the
237
- // resolved palette color through `modifyAlpha`. Per-row `itemStyle` on
238
+ // item's base colour through `modifyAlpha`. Per-row `itemStyle` on
238
239
  // dataset object-rows is silently ignored when `series.encode` is in
239
240
  // play — the callback approach is the standard ECharts pattern for
240
- // per-data styling derived from a dataset.
241
+ // per-data styling derived from a dataset. The base colour is the
242
+ // datum's `color`, else the template's `itemStyle.color`, else the
243
+ // series colour / palette (`params.color`).
241
244
  //
242
245
  // We *always* emit `itemStyle.color` (a passthrough when nothing is
243
246
  // selected), not conditionally — dropping the key between renders
@@ -245,22 +248,30 @@ export function createBarOptionFactory(
245
248
  // and bars would stay dimmed forever after an external clear. Always
246
249
  // emitting lets normal merge swap the callback in place, no
247
250
  // `replaceMerge` and no entry-animation flash on selection on/off.
248
- const dimItemStyle = {
249
- color: (params: CallbackDataParams) => {
250
- const base = params.color as string
251
- if (!selectionSet) return base
252
- const datum = params.value as { name?: string | number } | undefined
253
- const name = datum?.name ?? params.name
254
- return name != null && selectionSet.has(name)
255
- ? base
256
- : echarts.color.modifyAlpha(base, 0.15)
257
- },
251
+ const makeDimItemStyle = (datums: readonly object[], template: unknown) => {
252
+ const baseColor = createItemColorResolver({
253
+ theme,
254
+ datums,
255
+ template,
256
+ fallback: (params) => params.color as string,
257
+ })
258
+ return {
259
+ color: (params: CallbackDataParams) => {
260
+ const base = baseColor(params)
261
+ if (!selectionSet) return base
262
+ const datum = params.value as { name?: string | number } | undefined
263
+ const name = datum?.name ?? params.name
264
+ return name != null && selectionSet.has(name)
265
+ ? base
266
+ : echarts.color.modifyAlpha(base, 0.15)
267
+ },
268
+ }
258
269
  }
259
270
 
260
271
  return {
261
272
  ...option,
262
273
  dataset: seriesArr.map((s) => ({ source: s as readonly object[] })),
263
- series: seriesArr.map((_, i) => {
274
+ series: seriesArr.map((s, i) => {
264
275
  const template =
265
276
  (seriesTemplates[i] as object | undefined) ??
266
277
  (broadcastTemplate as object)
@@ -277,7 +288,7 @@ export function createBarOptionFactory(
277
288
  encode: { x: 'name', y: 'value' },
278
289
  barMaxWidth: 100,
279
290
  emphasis: { focus: 'series' },
280
- itemStyle: dimItemStyle,
291
+ itemStyle: makeDimItemStyle(s, template),
281
292
  ...(overrideColor ? { color: overrideColor } : {}),
282
293
  }
283
294
  }),
@@ -6,6 +6,12 @@ import type { WidgetSeries } from '../types'
6
6
  export interface BarDatum {
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
  /** v2 bar widget data shape: array of series, each a flat list of `{name, value}`. */
@@ -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
  }),