@carto/ps-react-ui 4.25.0 → 4.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/actions-DON6fSCh.js +1289 -0
  2. package/dist/actions-DON6fSCh.js.map +1 -0
  3. package/dist/category-BoT5FHHp.js +950 -0
  4. package/dist/category-BoT5FHHp.js.map +1 -0
  5. package/dist/item-color-C1cu6EJd.js +20 -0
  6. package/dist/item-color-C1cu6EJd.js.map +1 -0
  7. package/dist/scale-0elCv3tk.js +29 -0
  8. package/dist/scale-0elCv3tk.js.map +1 -0
  9. package/dist/types/widgets-v2/actions/index.d.ts +1 -1
  10. package/dist/types/widgets-v2/actions/relative-data/index.d.ts +1 -0
  11. package/dist/types/widgets-v2/actions/relative-data/labels.d.ts +3 -0
  12. package/dist/types/widgets-v2/actions/relative-data/relative-data.d.ts +16 -2
  13. package/dist/types/widgets-v2/actions/relative-data/scale.d.ts +26 -0
  14. package/dist/types/widgets-v2/actions/relative-data/style.d.ts +12 -0
  15. package/dist/types/widgets-v2/bar/types.d.ts +6 -0
  16. package/dist/types/widgets-v2/category/category.d.ts +2 -1
  17. package/dist/types/widgets-v2/histogram/bins.d.ts +7 -0
  18. package/dist/types/widgets-v2/histogram/download.d.ts +2 -2
  19. package/dist/types/widgets-v2/histogram/index.d.ts +2 -1
  20. package/dist/types/widgets-v2/histogram/options.d.ts +1 -1
  21. package/dist/types/widgets-v2/histogram/transforms.d.ts +5 -4
  22. package/dist/types/widgets-v2/histogram/types.d.ts +22 -5
  23. package/dist/types/widgets-v2/index.d.ts +2 -2
  24. package/dist/types/widgets-v2/pie/types.d.ts +6 -0
  25. package/dist/types/widgets-v2/scatterplot/download.d.ts +2 -2
  26. package/dist/types/widgets-v2/scatterplot/index.d.ts +2 -1
  27. package/dist/types/widgets-v2/scatterplot/points.d.ts +7 -0
  28. package/dist/types/widgets-v2/scatterplot/transforms.d.ts +4 -3
  29. package/dist/types/widgets-v2/scatterplot/types.d.ts +21 -2
  30. package/dist/types/widgets-v2/timeseries/types.d.ts +6 -0
  31. package/dist/types/widgets-v2/utils/item-color.d.ts +26 -0
  32. package/dist/widgets-v2/actions.js +1 -1
  33. package/dist/widgets-v2/bar.js +91 -81
  34. package/dist/widgets-v2/bar.js.map +1 -1
  35. package/dist/widgets-v2/category.js +1 -1
  36. package/dist/widgets-v2/histogram.js +133 -100
  37. package/dist/widgets-v2/histogram.js.map +1 -1
  38. package/dist/widgets-v2/pie.js +98 -78
  39. package/dist/widgets-v2/pie.js.map +1 -1
  40. package/dist/widgets-v2/scatterplot.js +151 -110
  41. package/dist/widgets-v2/scatterplot.js.map +1 -1
  42. package/dist/widgets-v2/timeseries.js +96 -86
  43. package/dist/widgets-v2/timeseries.js.map +1 -1
  44. package/dist/widgets-v2.js +2 -2
  45. package/dist/widgets-v2.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +77 -13
  48. package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +48 -5
  49. package/src/widgets-v2/actions/index.ts +1 -0
  50. package/src/widgets-v2/actions/relative-data/index.ts +1 -0
  51. package/src/widgets-v2/actions/relative-data/labels.ts +6 -0
  52. package/src/widgets-v2/actions/relative-data/relative-data.test.tsx +78 -1
  53. package/src/widgets-v2/actions/relative-data/relative-data.tsx +137 -20
  54. package/src/widgets-v2/actions/relative-data/scale.ts +48 -0
  55. package/src/widgets-v2/actions/relative-data/style.ts +10 -0
  56. package/src/widgets-v2/bar/options.test.ts +122 -0
  57. package/src/widgets-v2/bar/options.ts +31 -16
  58. package/src/widgets-v2/bar/types.ts +6 -0
  59. package/src/widgets-v2/category/category.relative-data.test.tsx +51 -63
  60. package/src/widgets-v2/category/category.tsx +25 -11
  61. package/src/widgets-v2/histogram/bins.ts +22 -0
  62. package/src/widgets-v2/histogram/download.test.ts +10 -0
  63. package/src/widgets-v2/histogram/download.ts +7 -3
  64. package/src/widgets-v2/histogram/index.ts +3 -0
  65. package/src/widgets-v2/histogram/options.test.ts +62 -0
  66. package/src/widgets-v2/histogram/options.ts +44 -19
  67. package/src/widgets-v2/histogram/transforms.test.ts +21 -7
  68. package/src/widgets-v2/histogram/transforms.ts +26 -12
  69. package/src/widgets-v2/histogram/types.ts +25 -5
  70. package/src/widgets-v2/index.ts +7 -1
  71. package/src/widgets-v2/pie/options.test.ts +91 -0
  72. package/src/widgets-v2/pie/options.ts +50 -20
  73. package/src/widgets-v2/pie/types.ts +6 -0
  74. package/src/widgets-v2/scatterplot/download.test.ts +9 -0
  75. package/src/widgets-v2/scatterplot/download.ts +5 -3
  76. package/src/widgets-v2/scatterplot/index.ts +3 -0
  77. package/src/widgets-v2/scatterplot/options.test.ts +90 -0
  78. package/src/widgets-v2/scatterplot/options.ts +53 -15
  79. package/src/widgets-v2/scatterplot/points.ts +35 -0
  80. package/src/widgets-v2/scatterplot/transforms.test.ts +19 -0
  81. package/src/widgets-v2/scatterplot/transforms.ts +23 -18
  82. package/src/widgets-v2/scatterplot/types.ts +25 -2
  83. package/src/widgets-v2/timeseries/options.test.ts +65 -0
  84. package/src/widgets-v2/timeseries/options.ts +38 -19
  85. package/src/widgets-v2/timeseries/types.ts +6 -0
  86. package/src/widgets-v2/utils/item-color.test.ts +74 -0
  87. package/src/widgets-v2/utils/item-color.ts +55 -0
  88. package/dist/actions-B1ss030_.js +0 -1167
  89. package/dist/actions-B1ss030_.js.map +0 -1
  90. package/dist/category-DuKx_CJX.js +0 -933
  91. package/dist/category-DuKx_CJX.js.map +0 -1
@@ -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,
@@ -19,6 +20,7 @@ import type {
19
20
  BarOptionsInput,
20
21
  BarWidgetData,
21
22
  } from './types'
23
+ import { fixedAxisBounds } from '../actions/relative-data/scale'
22
24
 
23
25
  /**
24
26
  * Builds the **structural** ECharts option for a bar widget — axes, grid,
@@ -201,6 +203,7 @@ export function createBarOptionFactory(
201
203
  typeof option.yAxis === 'object' && !Array.isArray(option.yAxis)
202
204
  ? option.yAxis
203
205
  : {}
206
+ const fixedY = fixedAxisBounds(baseYAxis)
204
207
 
205
208
  const seriesTemplates = Array.isArray(option.series) ? option.series : []
206
209
  const broadcastTemplate = seriesTemplates[0] ?? {}
@@ -234,10 +237,12 @@ export function createBarOptionFactory(
234
237
  : baseBottom
235
238
 
236
239
  // When a selection is active, dim non-selected bars by routing the
237
- // resolved palette color through `modifyAlpha`. Per-row `itemStyle` on
240
+ // item's base colour through `modifyAlpha`. Per-row `itemStyle` on
238
241
  // dataset object-rows is silently ignored when `series.encode` is in
239
242
  // play — the callback approach is the standard ECharts pattern for
240
- // per-data styling derived from a dataset.
243
+ // per-data styling derived from a dataset. The base colour is the
244
+ // datum's `color`, else the template's `itemStyle.color`, else the
245
+ // series colour / palette (`params.color`).
241
246
  //
242
247
  // We *always* emit `itemStyle.color` (a passthrough when nothing is
243
248
  // selected), not conditionally — dropping the key between renders
@@ -245,22 +250,30 @@ export function createBarOptionFactory(
245
250
  // and bars would stay dimmed forever after an external clear. Always
246
251
  // emitting lets normal merge swap the callback in place, no
247
252
  // `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
- },
253
+ const makeDimItemStyle = (datums: readonly object[], template: unknown) => {
254
+ const baseColor = createItemColorResolver({
255
+ theme,
256
+ datums,
257
+ template,
258
+ fallback: (params) => params.color as string,
259
+ })
260
+ return {
261
+ color: (params: CallbackDataParams) => {
262
+ const base = baseColor(params)
263
+ if (!selectionSet) return base
264
+ const datum = params.value as { name?: string | number } | undefined
265
+ const name = datum?.name ?? params.name
266
+ return name != null && selectionSet.has(name)
267
+ ? base
268
+ : echarts.color.modifyAlpha(base, 0.15)
269
+ },
270
+ }
258
271
  }
259
272
 
260
273
  return {
261
274
  ...option,
262
275
  dataset: seriesArr.map((s) => ({ source: s as readonly object[] })),
263
- series: seriesArr.map((_, i) => {
276
+ series: seriesArr.map((s, i) => {
264
277
  const template =
265
278
  (seriesTemplates[i] as object | undefined) ??
266
279
  (broadcastTemplate as object)
@@ -277,7 +290,7 @@ export function createBarOptionFactory(
277
290
  encode: { x: 'name', y: 'value' },
278
291
  barMaxWidth: 100,
279
292
  emphasis: { focus: 'series' },
280
- itemStyle: dimItemStyle,
293
+ itemStyle: makeDimItemStyle(s, template),
281
294
  ...(overrideColor ? { color: overrideColor } : {}),
282
295
  }
283
296
  }),
@@ -289,12 +302,14 @@ export function createBarOptionFactory(
289
302
  ...(dataZoomLayout ? { dataZoom: dataZoomLayout } : {}),
290
303
  yAxis: {
291
304
  ...baseYAxis,
305
+ // A config transform (RelativeData's `'total'` scale) may pin the
306
+ // ends; each applies only on the side the data reaches.
292
307
  min: (extent: { min: number }) => {
293
- niceMin = extent.min < 0 ? niceNum(extent.min) : 0
308
+ niceMin = extent.min < 0 ? (fixedY.min ?? niceNum(extent.min)) : 0
294
309
  return niceMin
295
310
  },
296
311
  max: (extent: { min: number; max: number }) => {
297
- niceMax = extent.max <= 0 ? 1 : niceNum(extent.max)
312
+ niceMax = extent.max <= 0 ? 1 : (fixedY.max ?? niceNum(extent.max))
298
313
  return niceMax
299
314
  },
300
315
  axisLabel: {
@@ -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}`. */
@@ -1,11 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
2
  import { fireEvent, render, screen } from '@testing-library/react'
3
3
  import { Provider } from '../provider/widget-provider'
4
- import {
5
- clearAllWidgetStores,
6
- useTransformEnabled,
7
- useWidgetId,
8
- } from '../stores'
4
+ import { clearAllWidgetStores, getWidgetStore } from '../stores'
9
5
  import { Category } from './category'
10
6
  import { RelativeData } from '../actions/relative-data/relative-data'
11
7
  import type { CategoryWidgetData } from './types'
@@ -13,21 +9,7 @@ import type { CategoryWidgetData } from './types'
13
9
  beforeEach(() => clearAllWidgetStores())
14
10
  afterEach(() => clearAllWidgetStores())
15
11
 
16
- // Replicates `CategoryActionWiring` from the storybook composer
17
- // (apps/storybook/stories/modules/widgets/category/category.tsx). The
18
- // composer-level wiring isn't testable in `apps/storybook` (no vitest
19
- // infra), so we re-implement just the relative-data branch here and
20
- // assert the bar-width math end-to-end through Provider → action →
21
- // bridge → UI.
22
- function Wired({ maxOverride }: { maxOverride?: number }) {
23
- const id = useWidgetId()
24
- const relativeDataActive = useTransformEnabled(id, 'relative-data')
25
- const effectiveMaxOverride = relativeDataActive ? 100 : maxOverride
26
- return <Category maxOverride={effectiveMaxOverride} />
27
- }
28
-
29
- // Single-series dataset where the max value is 1000 so percent values
30
- // map to round numbers we can match exactly against bar widths.
12
+ // Single series: shares of the 1750 total are ≈57.14 / 28.57 / 14.29.
31
13
  const DATA: CategoryWidgetData = [
32
14
  [
33
15
  { name: 'A', value: 1000 },
@@ -36,72 +18,78 @@ const DATA: CategoryWidgetData = [
36
18
  ],
37
19
  ]
38
20
 
39
- function getBarFills(): HTMLElement[] {
21
+ function widths(): number[] {
40
22
  return Array.from(
41
23
  document.querySelectorAll<HTMLElement>('[data-bar-fill="true"]'),
42
- )
24
+ ).map((el) => parseFloat(el.style.width))
43
25
  }
44
26
 
45
- describe('Category + RelativeData composer wiring', () => {
46
- it('default (RelativeData off) → bars scale to data max', () => {
27
+ const toggle = () =>
28
+ fireEvent.click(screen.getByRole('button', { name: /show relative values/i }))
29
+
30
+ describe('Category + RelativeData', () => {
31
+ it('off → bars scale to the data max', () => {
47
32
  render(
48
33
  <Provider id='cat-rd-off' data={DATA}>
49
34
  <RelativeData />
50
- <Wired />
35
+ <Category />
51
36
  </Provider>,
52
37
  )
53
- const fills = getBarFills()
54
- expect(fills).toHaveLength(3)
55
- // 1000/1000, 500/1000, 250/1000
56
- expect(fills[0]!.style.width).toBe('100%')
57
- expect(fills[1]!.style.width).toBe('50%')
58
- expect(fills[2]!.style.width).toBe('25%')
38
+ expect(widths()).toEqual([100, 50, 25])
59
39
  })
60
40
 
61
- it('clicking the % action flips bars to a 100-denominator', () => {
41
+ it("scale 'max' (default): values are shares of the total, the top bar fills", () => {
62
42
  render(
63
- <Provider id='cat-rd-toggle' data={DATA}>
43
+ <Provider id='cat-rd-max' data={DATA}>
64
44
  <RelativeData />
65
- <Wired />
45
+ <Category />
46
+ </Provider>,
47
+ )
48
+ toggle()
49
+ const values = (
50
+ getWidgetStore('cat-rd-max').getState().data as CategoryWidgetData
51
+ )[0]!.map((d) => d.value)
52
+ expect(values[0]).toBeCloseTo(57.14, 2)
53
+ const [a, b, c] = widths()
54
+ expect(a).toBeCloseTo(100)
55
+ expect(b).toBeCloseTo(50)
56
+ expect(c).toBeCloseTo(25)
57
+ })
58
+
59
+ it("scale 'total': bars end at 100%", () => {
60
+ render(
61
+ <Provider id='cat-rd-total' data={DATA}>
62
+ <RelativeData scale='total' />
63
+ <Category />
66
64
  </Provider>,
67
65
  )
68
- // Toggle ON. RelativeData rewrites data values into percentages
69
- // (A=1000/1750 ≈ 57.14, B=500/1750 ≈ 28.57, C=250/1750 ≈ 14.29) AND
70
- // the composer wiring forces maxOverride=100 so the bar widths
71
- // match those percent values directly.
72
- fireEvent.click(screen.getByRole('button', { name: /relative|percent/i }))
73
- const fills = getBarFills()
74
- expect(fills).toHaveLength(3)
75
- // Allow a small rounding tolerance — the percent transform produces
76
- // floats, the bar math multiplies by 100 again.
77
- const a = parseFloat(fills[0]!.style.width)
78
- const b = parseFloat(fills[1]!.style.width)
79
- const c = parseFloat(fills[2]!.style.width)
80
- expect(a).toBeGreaterThan(55)
81
- expect(a).toBeLessThan(60)
82
- expect(b).toBeGreaterThan(27)
83
- expect(b).toBeLessThan(30)
84
- expect(c).toBeGreaterThan(13)
85
- expect(c).toBeLessThan(16)
86
- // The largest bar must be NOT 100% — that's the whole point of the
87
- // fix. Without the wiring, A would have been 100% (largest of the
88
- // percent series).
89
- expect(a).toBeLessThan(100)
66
+ toggle()
67
+ const [a, b, c] = widths()
68
+ expect(a).toBeCloseTo(57.14, 1)
69
+ expect(b).toBeCloseTo(28.57, 1)
70
+ expect(c).toBeCloseTo(14.29, 1)
71
+ })
72
+
73
+ it('ignores maxOverride while relative is on (legacy ±100 wiring)', () => {
74
+ render(
75
+ <Provider id='cat-rd-legacy' data={DATA}>
76
+ <RelativeData initialEnabled />
77
+ <Category maxOverride={100} minOverride={-100} />
78
+ </Provider>,
79
+ )
80
+ expect(widths()[0]).toBeCloseTo(100)
90
81
  })
91
82
 
92
83
  it('toggling off again restores data-max scaling', () => {
93
84
  render(
94
85
  <Provider id='cat-rd-restore' data={DATA}>
95
- <RelativeData />
96
- <Wired />
86
+ <RelativeData scale='total' />
87
+ <Category />
97
88
  </Provider>,
98
89
  )
99
- const button = screen.getByRole('button', { name: /relative|percent/i })
90
+ const button = screen.getByRole('button', { name: /show relative values/i })
100
91
  fireEvent.click(button) // on
101
92
  fireEvent.click(button) // off
102
- const fills = getBarFills()
103
- expect(fills[0]!.style.width).toBe('100%')
104
- expect(fills[1]!.style.width).toBe('50%')
105
- expect(fills[2]!.style.width).toBe('25%')
93
+ expect(widths()).toEqual([100, 50, 25])
106
94
  })
107
95
  })
@@ -6,6 +6,7 @@ import {
6
6
  useWidgetShallow,
7
7
  type WidgetState,
8
8
  } from '../stores'
9
+ import { selectRelativeScale } from '../actions/relative-data/scale'
9
10
  import { CategoryUI } from './category-ui'
10
11
  import type {
11
12
  CategoryKey,
@@ -87,7 +88,8 @@ export interface CategoryProps {
87
88
  * stay coherent across data transforms (e.g., the Searcher filtering
88
89
  * rows in and out — bars don't rescale just because the larger rows
89
90
  * got hidden). Pass an explicit number to fix the denominator
90
- * regardless of the data.
91
+ * regardless of the data. Ignored while `RelativeData` is on, whose
92
+ * `scale` decides the denominator instead.
91
93
  */
92
94
  maxOverride?: number
93
95
  /**
@@ -134,18 +136,30 @@ export function Category({
134
136
  // Auto-fill both ends of the scale from `rawData` so bar widths and the
135
137
  // zero stay coherent across data transforms (e.g., the Searcher filtering
136
138
  // rows in/out). Consumer's explicit value wins, but each end only applies
137
- // when `rawData` reaches past zero on that side: a relative-data wiring can
138
- // then pass `100` / `-100` unconditionally and positive-only data keeps its
139
- // zero at the left edge, negative-only at the right. With no data on a side
140
- // we pass nothing and CategoryUI falls back to the live `data`.
141
- const [naturalMin, naturalMax] = useMemo(
142
- () => extentFromCategoryData(slice.rawData),
143
- [slice.rawData],
144
- )
139
+ // when the data reaches past zero on that side, so positive-only data
140
+ // keeps its zero at the left edge, negative-only at the right. With no
141
+ // data on a side we pass nothing and CategoryUI falls back to the live
142
+ // `data`.
143
+ // With RelativeData on, the values are percentages: `'total'` ends the
144
+ // scale at ±100, `'max'` at the largest share so the top row fills.
145
+ const relativeScale = useWidget(id, selectRelativeScale)
146
+ const [naturalMin, naturalMax] = useMemo(() => {
147
+ const [min, max] = extentFromCategoryData(slice.rawData)
148
+ if (relativeScale === 'total')
149
+ return [min < 0 ? -100 : 0, max > 0 ? 100 : 0]
150
+ if (relativeScale === 'max') return extentFromCategoryData(slice.data)
151
+ return [min, max]
152
+ }, [slice.rawData, slice.data, relativeScale])
153
+ // The overrides are in absolute units, so they sit out while relative
154
+ // values are on.
145
155
  const effectiveMaxOverride =
146
- naturalMax > 0 ? (maxOverride ?? naturalMax) : undefined
156
+ naturalMax > 0
157
+ ? ((relativeScale ? undefined : maxOverride) ?? naturalMax)
158
+ : undefined
147
159
  const effectiveMinOverride =
148
- naturalMin < 0 ? (minOverride ?? naturalMin) : undefined
160
+ naturalMin < 0
161
+ ? ((relativeScale ? undefined : minOverride) ?? naturalMin)
162
+ : undefined
149
163
 
150
164
  const seriesKey = JSON.stringify(series?.map((s) => s.name) ?? [])
151
165
  const stored = useWidget<
@@ -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,13 +12,17 @@ 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'
25
+ import { fixedAxisBounds } from '../actions/relative-data/scale'
22
26
 
23
27
  /**
24
28
  * Builds the **structural** ECharts option for a histogram widget — axes,
@@ -147,7 +151,7 @@ export function histogramOptions({
147
151
  * the consumer-supplied `optionsOverride`. Called once by Provider to
148
152
  * seed `rawOptions` in the store.
149
153
  * - **Merge phase** (`option != null`) — fuses post-pipeline `state.data`
150
- * (`HistogramWidgetData` — `number[][]`) into the option via the
154
+ * (`HistogramWidgetData` — `HistogramBin[][]`) into the option via the
151
155
  * dataset API. Each series gets its own dataset of `[binLabel, count]`
152
156
  * pairs derived from `ticks`; series reference their dataset by
153
157
  * `datasetIndex` and are encoded by column position. Reactive
@@ -199,8 +203,8 @@ export function createHistogramOptionFactory(
199
203
  : structural
200
204
  }
201
205
 
202
- const seriesArr: HistogramWidgetData = Array.isArray(data)
203
- ? (data as HistogramWidgetData)
206
+ const seriesArr: HistogramWidgetData<HistogramBin> = Array.isArray(data)
207
+ ? (data as HistogramWidgetData<HistogramBin>)
204
208
  : []
205
209
  if (seriesArr.length === 0 || binLabels.length === 0) {
206
210
  return { ...option, dataset: [], series: [] }
@@ -213,6 +217,7 @@ export function createHistogramOptionFactory(
213
217
  typeof option.yAxis === 'object' && !Array.isArray(option.yAxis)
214
218
  ? option.yAxis
215
219
  : {}
220
+ const fixedY = fixedAxisBounds(baseYAxis)
216
221
  const baseGrid =
217
222
  typeof option.grid === 'object' && !Array.isArray(option.grid)
218
223
  ? option.grid
@@ -261,14 +266,28 @@ export function createHistogramOptionFactory(
261
266
  // and bins would stay dimmed forever after an external clear. Always
262
267
  // emitting lets normal merge swap the callback in place, no
263
268
  // `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
- },
269
+ //
270
+ // The base colour is the bin's own `color` (object bins), else the
271
+ // template's `itemStyle.color`, else the series colour / palette.
272
+ const makeDimItemStyle = (
273
+ datums: readonly unknown[],
274
+ template: unknown,
275
+ ) => {
276
+ const baseColor = createItemColorResolver({
277
+ theme,
278
+ datums,
279
+ template,
280
+ fallback: (params) => params.color as string,
281
+ })
282
+ return {
283
+ color: (params: CallbackDataParams) => {
284
+ const base = baseColor(params)
285
+ if (!selectionSet) return base
286
+ return selectionSet.has(params.dataIndex)
287
+ ? base
288
+ : echarts.color.modifyAlpha(base, 0.15)
289
+ },
290
+ }
272
291
  }
273
292
 
274
293
  // A consumer's `optionsOverride.xAxis` may legitimately be an array
@@ -306,12 +325,16 @@ export function createHistogramOptionFactory(
306
325
  return {
307
326
  ...option,
308
327
  xAxis: nextXAxis,
309
- dataset: seriesArr.map((counts) => ({
310
- source: binLabels.map(
311
- (label, i) => [label, counts[i] ?? 0] as [string, number],
312
- ),
328
+ dataset: seriesArr.map((bins) => ({
329
+ source: binLabels.map((label, i) => {
330
+ const bin = bins[i]
331
+ return [label, bin == null ? 0 : getHistogramBinValue(bin)] as [
332
+ string,
333
+ number,
334
+ ]
335
+ }),
313
336
  })),
314
- series: seriesArr.map((_, i) => {
337
+ series: seriesArr.map((bins, i) => {
315
338
  const template =
316
339
  (seriesTemplates[i] as object | undefined) ??
317
340
  (broadcastTemplate as object)
@@ -324,7 +347,7 @@ export function createHistogramOptionFactory(
324
347
  encode: { x: 0, y: 1 },
325
348
  barCategoryGap: '0%',
326
349
  emphasis: { focus: 'series' },
327
- itemStyle: dimItemStyle,
350
+ itemStyle: makeDimItemStyle(bins, template),
328
351
  ...(overrideColor ? { color: overrideColor } : {}),
329
352
  }
330
353
  }),
@@ -333,12 +356,14 @@ export function createHistogramOptionFactory(
333
356
  ...(dataZoomLayout ? { dataZoom: dataZoomLayout } : {}),
334
357
  yAxis: {
335
358
  ...baseYAxis,
359
+ // A config transform (RelativeData's `'total'` scale) may pin the
360
+ // ends; each applies only on the side the data reaches.
336
361
  min: (extent: { min: number }) => {
337
- niceMin = extent.min < 0 ? niceNum(extent.min) : 0
362
+ niceMin = extent.min < 0 ? (fixedY.min ?? niceNum(extent.min)) : 0
338
363
  return niceMin
339
364
  },
340
365
  max: (extent: { min: number; max: number }) => {
341
- niceMax = extent.max <= 0 ? 1 : niceNum(extent.max)
366
+ niceMax = extent.max <= 0 ? 1 : (fixedY.max ?? niceNum(extent.max))
342
367
  return niceMax
343
368
  },
344
369
  axisLabel: {