@carto/ps-react-ui 6.2.0 → 6.4.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-DyWNwvLu.js +1272 -0
  2. package/dist/actions-DyWNwvLu.js.map +1 -0
  3. package/dist/{category-DULnoxvk.js → category-BHBd5Pcc.js} +262 -245
  4. package/dist/category-BHBd5Pcc.js.map +1 -0
  5. package/dist/{option-builders-DQivvvbb.js → item-color-B7xkIpDZ.js} +26 -9
  6. package/dist/item-color-B7xkIpDZ.js.map +1 -0
  7. package/dist/scale-Bhx8HKEB.js +29 -0
  8. package/dist/scale-Bhx8HKEB.js.map +1 -0
  9. package/dist/types/widgets/actions/index.d.ts +1 -1
  10. package/dist/types/widgets/actions/relative-data/index.d.ts +1 -0
  11. package/dist/types/widgets/actions/relative-data/labels.d.ts +3 -0
  12. package/dist/types/widgets/actions/relative-data/relative-data.d.ts +16 -2
  13. package/dist/types/widgets/actions/relative-data/scale.d.ts +26 -0
  14. package/dist/types/widgets/actions/relative-data/style.d.ts +12 -0
  15. package/dist/types/widgets/bar/types.d.ts +6 -0
  16. package/dist/types/widgets/category/category.d.ts +2 -1
  17. package/dist/types/widgets/histogram/bins.d.ts +7 -0
  18. package/dist/types/widgets/histogram/download.d.ts +2 -2
  19. package/dist/types/widgets/histogram/index.d.ts +2 -1
  20. package/dist/types/widgets/histogram/options.d.ts +1 -1
  21. package/dist/types/widgets/histogram/transforms.d.ts +5 -4
  22. package/dist/types/widgets/histogram/types.d.ts +22 -5
  23. package/dist/types/widgets/index.d.ts +2 -2
  24. package/dist/types/widgets/pie/types.d.ts +6 -0
  25. package/dist/types/widgets/scatterplot/download.d.ts +2 -2
  26. package/dist/types/widgets/scatterplot/index.d.ts +2 -1
  27. package/dist/types/widgets/scatterplot/points.d.ts +7 -0
  28. package/dist/types/widgets/scatterplot/transforms.d.ts +4 -3
  29. package/dist/types/widgets/scatterplot/types.d.ts +21 -2
  30. package/dist/types/widgets/timeseries/types.d.ts +6 -0
  31. package/dist/types/widgets/utils/item-color.d.ts +26 -0
  32. package/dist/widgets/actions.js +1 -1
  33. package/dist/widgets/bar.js +87 -78
  34. package/dist/widgets/bar.js.map +1 -1
  35. package/dist/widgets/category.js +1 -1
  36. package/dist/widgets/histogram.js +129 -97
  37. package/dist/widgets/histogram.js.map +1 -1
  38. package/dist/widgets/pie.js +108 -89
  39. package/dist/widgets/pie.js.map +1 -1
  40. package/dist/widgets/scatterplot.js +147 -107
  41. package/dist/widgets/scatterplot.js.map +1 -1
  42. package/dist/widgets/timeseries.js +92 -83
  43. package/dist/widgets/timeseries.js.map +1 -1
  44. package/dist/widgets.js +2 -2
  45. package/dist/widgets.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/widgets/actions/fullscreen/fullscreen-store.test.tsx +77 -13
  48. package/src/widgets/actions/fullscreen/fullscreen.tsx +48 -5
  49. package/src/widgets/actions/index.ts +1 -0
  50. package/src/widgets/actions/relative-data/index.ts +1 -0
  51. package/src/widgets/actions/relative-data/labels.ts +6 -0
  52. package/src/widgets/actions/relative-data/relative-data.test.tsx +78 -1
  53. package/src/widgets/actions/relative-data/relative-data.tsx +137 -20
  54. package/src/widgets/actions/relative-data/scale.ts +48 -0
  55. package/src/widgets/actions/relative-data/style.ts +10 -0
  56. package/src/widgets/bar/options.test.ts +122 -0
  57. package/src/widgets/bar/options.ts +31 -16
  58. package/src/widgets/bar/types.ts +6 -0
  59. package/src/widgets/category/category.relative-data.test.tsx +51 -63
  60. package/src/widgets/category/category.tsx +25 -11
  61. package/src/widgets/histogram/bins.ts +22 -0
  62. package/src/widgets/histogram/download.test.ts +10 -0
  63. package/src/widgets/histogram/download.ts +7 -3
  64. package/src/widgets/histogram/index.ts +3 -0
  65. package/src/widgets/histogram/options.test.ts +62 -0
  66. package/src/widgets/histogram/options.ts +44 -19
  67. package/src/widgets/histogram/transforms.test.ts +21 -7
  68. package/src/widgets/histogram/transforms.ts +26 -12
  69. package/src/widgets/histogram/types.ts +25 -5
  70. package/src/widgets/index.ts +7 -1
  71. package/src/widgets/pie/options.test.ts +91 -0
  72. package/src/widgets/pie/options.ts +50 -20
  73. package/src/widgets/pie/types.ts +6 -0
  74. package/src/widgets/scatterplot/download.test.ts +9 -0
  75. package/src/widgets/scatterplot/download.ts +5 -3
  76. package/src/widgets/scatterplot/index.ts +3 -0
  77. package/src/widgets/scatterplot/options.test.ts +90 -0
  78. package/src/widgets/scatterplot/options.ts +53 -15
  79. package/src/widgets/scatterplot/points.ts +35 -0
  80. package/src/widgets/scatterplot/transforms.test.ts +19 -0
  81. package/src/widgets/scatterplot/transforms.ts +23 -18
  82. package/src/widgets/scatterplot/types.ts +25 -2
  83. package/src/widgets/timeseries/options.test.ts +65 -0
  84. package/src/widgets/timeseries/options.ts +37 -16
  85. package/src/widgets/timeseries/types.ts +6 -0
  86. package/src/widgets/utils/item-color.test.ts +74 -0
  87. package/src/widgets/utils/item-color.ts +55 -0
  88. package/dist/actions-CueYrfVr.js +0 -1150
  89. package/dist/actions-CueYrfVr.js.map +0 -1
  90. package/dist/category-DULnoxvk.js.map +0 -1
  91. package/dist/option-builders-DQivvvbb.js.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import * as echarts from 'echarts'
2
3
  import { createTheme, type Theme } from '@mui/material'
3
4
  import { barOptions, createBarOptionFactory } from './options'
4
5
 
@@ -236,6 +237,22 @@ describe('createBarOptionFactory', () => {
236
237
  expect(out.yAxis.axisLabel.formatter(0)).toBe('')
237
238
  })
238
239
 
240
+ it("pins the axis ends a config transform set (RelativeData 'total')", () => {
241
+ const merge = createBarOptionFactory({ theme })
242
+ const out = merge({ yAxis: { type: 'value', min: -100, max: 100 } }, [
243
+ [{ name: 'A', value: 57 }],
244
+ ]) as {
245
+ yAxis: {
246
+ min: (extent: { min: number }) => number
247
+ max: (extent: { min: number; max: number }) => number
248
+ }
249
+ }
250
+ expect(out.yAxis.max({ min: 0, max: 57 })).toBe(100)
251
+ // The data never goes below zero, so the -100 end does not apply.
252
+ expect(out.yAxis.min({ min: 0 })).toBe(0)
253
+ expect(out.yAxis.min({ min: -20 })).toBe(-100)
254
+ })
255
+
239
256
  it('rounds the ECharts-supplied (stacked) extent so stacked bars cannot overflow', () => {
240
257
  // Regression: previously the merge phase precomputed the y-axis max from
241
258
  // the largest *single* series value, so stacking (StackToggle →
@@ -359,3 +376,108 @@ describe('createBarOptionFactory', () => {
359
376
  expect(html).toContain('[ITEM:A]')
360
377
  })
361
378
  })
379
+
380
+ describe('createBarOptionFactory — per-item colour', () => {
381
+ interface Out {
382
+ series: { itemStyle: { color: (p: unknown) => string } }[]
383
+ }
384
+ const data = [
385
+ [
386
+ { name: 'A', value: 1, color: '#00ff00' },
387
+ { name: 'B', value: -2 },
388
+ ],
389
+ ]
390
+ const dim = (c: string) => echarts.color.modifyAlpha(c, 0.15)
391
+
392
+ it('datum colour wins over the series colour', () => {
393
+ const merge = createBarOptionFactory({
394
+ theme,
395
+ series: [{ name: 'S', color: '#0000ff' }],
396
+ })
397
+ const out = merge({}, data) as Out
398
+ const colorFn = out.series[0]!.itemStyle.color
399
+ expect(colorFn({ dataIndex: 0, color: '#0000ff', name: 'A' })).toBe(
400
+ '#00ff00',
401
+ )
402
+ expect(colorFn({ dataIndex: 1, color: '#0000ff', name: 'B' })).toBe(
403
+ '#0000ff',
404
+ )
405
+ })
406
+
407
+ it('uses a string template itemStyle.color from optionsOverride', () => {
408
+ const factory = createBarOptionFactory({
409
+ theme,
410
+ series: [{ name: 'S', color: '#0000ff' }],
411
+ optionsOverride: {
412
+ series: [{ type: 'bar', itemStyle: { color: '#123456' } }],
413
+ },
414
+ })
415
+ const out = factory(factory(undefined, undefined), data) as Out
416
+ const colorFn = out.series[0]!.itemStyle.color
417
+ expect(colorFn({ dataIndex: 0, color: '#0000ff', name: 'A' })).toBe(
418
+ '#00ff00',
419
+ )
420
+ expect(colorFn({ dataIndex: 1, color: '#0000ff', name: 'B' })).toBe(
421
+ '#123456',
422
+ )
423
+ })
424
+
425
+ it('calls a function template itemStyle.color with the ECharts params', () => {
426
+ const merge = createBarOptionFactory({ theme })
427
+ const out = merge(
428
+ {
429
+ series: [
430
+ {
431
+ type: 'bar',
432
+ itemStyle: {
433
+ color: (p: { dataIndex: number }) =>
434
+ p.dataIndex === 1 ? '#ff0000' : '#00ff00',
435
+ },
436
+ },
437
+ ],
438
+ },
439
+ [
440
+ [
441
+ { name: 'A', value: 1 },
442
+ { name: 'B', value: -2 },
443
+ ],
444
+ ],
445
+ ) as Out
446
+ const colorFn = out.series[0]!.itemStyle.color
447
+ expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe('#00ff00')
448
+ expect(colorFn({ dataIndex: 1, color: '#abc', name: 'B' })).toBe('#ff0000')
449
+ })
450
+
451
+ it('resolves theme palette paths on the datum colour', () => {
452
+ const merge = createBarOptionFactory({ theme })
453
+ const out = merge({}, [[{ name: 'A', value: 1, color: 'primary.main' }]])
454
+ const colorFn = (out as Out).series[0]!.itemStyle.color
455
+ expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe(
456
+ theme.palette.primary.main,
457
+ )
458
+ })
459
+
460
+ it('dims both datum and template colours outside the selection', () => {
461
+ const merge = createBarOptionFactory({ theme, selection: ['Z'] })
462
+ const out = merge(
463
+ { series: [{ type: 'bar', itemStyle: { color: '#123456' } }] },
464
+ data,
465
+ ) as Out
466
+ const colorFn = out.series[0]!.itemStyle.color
467
+ expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe(
468
+ dim('#00ff00'),
469
+ )
470
+ expect(colorFn({ dataIndex: 1, color: '#abc', name: 'B' })).toBe(
471
+ dim('#123456'),
472
+ )
473
+ })
474
+
475
+ it('keeps the series / palette colour when no item colour is given', () => {
476
+ const merge = createBarOptionFactory({ theme, selection: ['Z'] })
477
+ const out = merge({}, [[{ name: 'A', value: 1 }]]) as Out
478
+ const colorFn = out.series[0]!.itemStyle.color
479
+ expect(colorFn({ dataIndex: 0, color: '#abcdef', name: 'A' })).toBe(
480
+ dim('#abcdef'),
481
+ )
482
+ })
483
+ })
@@ -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 { categorySeriesKey, NO_HIDDEN_SERIES } from './helpers'
11
12
  import type {
@@ -86,7 +87,8 @@ export interface CategoryProps {
86
87
  * stay coherent across data transforms (e.g., the Searcher filtering
87
88
  * rows in and out — bars don't rescale just because the larger rows
88
89
  * got hidden). Pass an explicit number to fix the denominator
89
- * regardless of the data.
90
+ * regardless of the data. Ignored while `RelativeData` is on, whose
91
+ * `scale` decides the denominator instead.
90
92
  */
91
93
  maxOverride?: number
92
94
  /**
@@ -133,18 +135,30 @@ export function Category({
133
135
  // Auto-fill both ends of the scale from `rawData` so bar widths and the
134
136
  // zero stay coherent across data transforms (e.g., the Searcher filtering
135
137
  // rows in/out). Consumer's explicit value wins, but each end only applies
136
- // when `rawData` reaches past zero on that side: a relative-data wiring can
137
- // then pass `100` / `-100` unconditionally and positive-only data keeps its
138
- // zero at the left edge, negative-only at the right. With no data on a side
139
- // we pass nothing and CategoryUI falls back to the live `data`.
140
- const [naturalMin, naturalMax] = useMemo(
141
- () => extentFromCategoryData(slice.rawData),
142
- [slice.rawData],
143
- )
138
+ // when the data reaches past zero on that side, so positive-only data
139
+ // keeps its zero at the left edge, negative-only at the right. With no
140
+ // data on a side we pass nothing and CategoryUI falls back to the live
141
+ // `data`.
142
+ // With RelativeData on, the values are percentages: `'total'` ends the
143
+ // scale at ±100, `'max'` at the largest share so the top row fills.
144
+ const relativeScale = useWidget(id, selectRelativeScale)
145
+ const [naturalMin, naturalMax] = useMemo(() => {
146
+ const [min, max] = extentFromCategoryData(slice.rawData)
147
+ if (relativeScale === 'total')
148
+ return [min < 0 ? -100 : 0, max > 0 ? 100 : 0]
149
+ if (relativeScale === 'max') return extentFromCategoryData(slice.data)
150
+ return [min, max]
151
+ }, [slice.rawData, slice.data, relativeScale])
152
+ // The overrides are in absolute units, so they sit out while relative
153
+ // values are on.
144
154
  const effectiveMaxOverride =
145
- naturalMax > 0 ? (maxOverride ?? naturalMax) : undefined
155
+ naturalMax > 0
156
+ ? ((relativeScale ? undefined : maxOverride) ?? naturalMax)
157
+ : undefined
146
158
  const effectiveMinOverride =
147
- naturalMin < 0 ? (minOverride ?? naturalMin) : undefined
159
+ naturalMin < 0
160
+ ? ((relativeScale ? undefined : minOverride) ?? naturalMin)
161
+ : undefined
148
162
 
149
163
  const seriesKey = categorySeriesKey(series)
150
164
  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 DownloadItem,
5
5
  type WidgetDownloadArgs,
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
@@ -13,7 +14,7 @@ import type { HistogramWidgetData } from './types'
13
14
  * rasterises the captured element via `html2canvas`.
14
15
  */
15
16
  export function createHistogramDownloadConfig(
16
- args: WidgetDownloadArgs<HistogramWidgetData> & {
17
+ args: WidgetDownloadArgs<HistogramWidgetData<HistogramBin>> & {
17
18
  getTicks: () => readonly number[]
18
19
  seriesNames?: readonly string[]
19
20
  },
@@ -33,7 +34,10 @@ export function createHistogramDownloadConfig(
33
34
  const rows: unknown[][] = [header]
34
35
  for (let bin = 0; bin < Math.max(0, ticks.length - 1); bin++) {
35
36
  const row: unknown[] = [ticks[bin], ticks[bin + 1]]
36
- for (let s = 0; s < seriesCount; s++) row.push(data[s]?.[bin] ?? 0)
37
+ for (let s = 0; s < seriesCount; s++) {
38
+ const value = data[s]?.[bin]
39
+ row.push(value == null ? 0 : getHistogramBinValue(value))
40
+ }
37
41
  rows.push(row)
38
42
  }
39
43
  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
+ })