@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
@@ -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
  *
@@ -46,6 +46,7 @@ export {
46
46
  DEFAULT_RELATIVE_DATA_LABELS,
47
47
  type RelativeDataProps,
48
48
  type RelativeDataLabels,
49
+ type RelativeScale,
49
50
  } from './relative-data'
50
51
  export {
51
52
  Download,
@@ -1,3 +1,4 @@
1
1
  export { RelativeData, type RelativeDataProps } from './relative-data'
2
2
  export { toRelativeData, createPercentFormatter } from './transforms'
3
+ export type { RelativeScale } from './scale'
3
4
  export { DEFAULT_RELATIVE_DATA_LABELS, type RelativeDataLabels } from './labels'
@@ -1,9 +1,15 @@
1
1
  export interface RelativeDataLabels {
2
2
  on: string
3
3
  off: string
4
+ scaleMenu: string
5
+ scaleMax: string
6
+ scaleTotal: string
4
7
  }
5
8
 
6
9
  export const DEFAULT_RELATIVE_DATA_LABELS: RelativeDataLabels = {
7
10
  on: 'Show absolute values',
8
11
  off: 'Show relative values',
12
+ scaleMenu: 'Relative scale',
13
+ scaleMax: 'Relative to max',
14
+ scaleTotal: 'Relative to total',
9
15
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
- import { render } from '@testing-library/react'
2
+ import { act, fireEvent, render, screen } from '@testing-library/react'
3
3
  import { Provider } from '../../provider/widget-provider'
4
4
  import { clearAllWidgetStores, getWidgetStore } from '../../stores'
5
5
  import { RelativeData } from './relative-data'
@@ -69,3 +69,80 @@ describe('<RelativeData> — rawFormatter-driven restoration', () => {
69
69
  expect(s.formatter).toBe(fmtA)
70
70
  })
71
71
  })
72
+
73
+ describe('<RelativeData> — scale', () => {
74
+ const DATA = [
75
+ [
76
+ { name: 'a', value: 50 },
77
+ { name: 'b', value: 150 },
78
+ ],
79
+ ]
80
+ const values = (id: string) =>
81
+ (getWidgetStore(id).getState().data as { value: number }[][])[0]!.map(
82
+ (d) => d.value,
83
+ )
84
+ // The value axis a structural option ends up with after the action's
85
+ // config transform.
86
+ const yAxis = (id: string) => {
87
+ const xf = getWidgetStore(id)
88
+ .getState()
89
+ .configTransforms.find((t) => t.id === 'relative-data-axis')!
90
+ return (xf.fn({ yAxis: { type: 'value' } }) as { yAxis: object }).yAxis
91
+ }
92
+ const slot = (id: string) =>
93
+ getWidgetStore(id).getState().transformStates['relative-data']
94
+
95
+ it("defaults to 'max': values are shares of the total, axis left to the data", () => {
96
+ render(
97
+ <Provider id='sc1' data={DATA}>
98
+ <RelativeData initialEnabled />
99
+ </Provider>,
100
+ )
101
+ expect(values('sc1')).toEqual([25, 75])
102
+ expect(yAxis('sc1')).toEqual({ type: 'value' })
103
+ expect(slot('sc1')).toMatchObject({ enabled: true, scale: 'max' })
104
+ })
105
+
106
+ it("scale='total' keeps the same values and pins the axis to ±100", () => {
107
+ render(
108
+ <Provider id='sc2' data={DATA}>
109
+ <RelativeData initialEnabled scale='total' />
110
+ </Provider>,
111
+ )
112
+ expect(values('sc2')).toEqual([25, 75])
113
+ expect(yAxis('sc2')).toEqual({ type: 'value', min: -100, max: 100 })
114
+ })
115
+
116
+ it('picking a scale in the menu switches it and turns relative on', () => {
117
+ render(
118
+ <Provider id='sc3' data={DATA}>
119
+ <RelativeData />
120
+ </Provider>,
121
+ )
122
+ fireEvent.click(screen.getByRole('button', { name: 'Relative scale' }))
123
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Relative to total' }))
124
+ expect(slot('sc3')).toMatchObject({ enabled: true, scale: 'total' })
125
+ expect(values('sc3')).toEqual([25, 75])
126
+ expect(yAxis('sc3')).toMatchObject({ max: 100 })
127
+ })
128
+
129
+ it('follows a scale written to the store from outside', () => {
130
+ render(
131
+ <Provider id='sc4' data={DATA}>
132
+ <RelativeData initialEnabled />
133
+ </Provider>,
134
+ )
135
+ act(() => {
136
+ getWidgetStore('sc4').setState((s) => ({
137
+ transformStates: {
138
+ ...s.transformStates,
139
+ 'relative-data': {
140
+ ...s.transformStates['relative-data']!,
141
+ scale: 'total',
142
+ },
143
+ },
144
+ }))
145
+ })
146
+ expect(yAxis('sc4')).toMatchObject({ max: 100 })
147
+ })
148
+ })
@@ -1,25 +1,56 @@
1
- import { useEffect, useMemo, type ComponentType } from 'react'
2
- import { IconButton, type SvgIconProps } from '@mui/material'
3
- import { Percent as PercentIcon } from '@mui/icons-material'
1
+ import { useEffect, useMemo, useState, type ComponentType } from 'react'
2
+ import {
3
+ Box,
4
+ IconButton,
5
+ ListItemText,
6
+ Menu,
7
+ MenuItem,
8
+ type SvgIconProps,
9
+ } from '@mui/material'
10
+ import {
11
+ ArrowDropDown as ArrowDropDownIcon,
12
+ Percent as PercentIcon,
13
+ } from '@mui/icons-material'
4
14
  import { Tooltip } from '../../../components'
5
15
  import {
6
16
  getWidgetStore,
7
- useSingleTransform,
17
+ useTransform,
8
18
  useWidget,
9
19
  useWidgetId,
20
+ type TransformPair,
10
21
  } from '../../stores'
11
22
  import { createPercentFormatter, toRelativeData } from './transforms'
23
+ import {
24
+ RELATIVE_DATA_ID,
25
+ toRelativeTotalAxis,
26
+ type RelativeScale,
27
+ } from './scale'
12
28
  import { DEFAULT_RELATIVE_DATA_LABELS, type RelativeDataLabels } from './labels'
13
29
  import { styles } from './style'
14
30
 
15
31
  const DATA_DESCRIPTOR = {
16
- id: 'relative-data',
32
+ id: RELATIVE_DATA_ID,
17
33
  type: 'data' as const,
18
34
  order: 15,
19
35
  }
20
36
 
37
+ const AXIS_DESCRIPTOR = {
38
+ id: 'relative-data-axis',
39
+ type: 'config' as const,
40
+ order: 15,
41
+ }
42
+
43
+ const identity = (option: unknown) => option
44
+
21
45
  export interface RelativeDataProps {
22
46
  initialEnabled?: boolean
47
+ /**
48
+ * Initial bar scale: `'max'` (the largest item fills its bar) or
49
+ * `'total'` (bars end at 100%). Values are the share of the total
50
+ * either way. Only seeds the store — the menu, or a write to
51
+ * `transformStates['relative-data'].scale`, changes it afterwards.
52
+ */
53
+ scale?: RelativeScale
23
54
  /**
24
55
  * BCP-47 locale tag forwarded to `Intl.NumberFormat`. Defaults to the
25
56
  * runtime locale.
@@ -43,7 +74,8 @@ export interface RelativeDataProps {
43
74
  }
44
75
 
45
76
  /**
46
- * Toggle between absolute and relative (percentage) values.
77
+ * Toggle between absolute and relative (percentage) values, as a split
78
+ * button: the toggle plus an arrow that opens the scale menu.
47
79
  *
48
80
  * - **Data side**: a single data transform ({@link toRelativeData}) registered
49
81
  * through `useSingleTransform`. When disabled, the pipeline skips it and the
@@ -53,9 +85,15 @@ export interface RelativeDataProps {
53
85
  * reads `state.rawFormatter` (the consumer-supplied original, always live)
54
86
  * and writes it back to `formatter`. No "original" snapshot is captured —
55
87
  * `rawFormatter` is the original.
88
+ * - **Scale**: lives in `transformStates['relative-data'].scale` next to
89
+ * `enabled`, so duplicate mounts (Toolbox inline + overflow) and
90
+ * `keepAlive` remounts agree on it. It never changes the values; under
91
+ * `'total'` a config transform pins the ECharts value axis to 100%, and
92
+ * Category reads it through `selectRelativeScale`.
56
93
  */
57
94
  export function RelativeData({
58
95
  initialEnabled = false,
96
+ scale: initialScale = 'max',
59
97
  locale,
60
98
  labels,
61
99
  icon: Icon = PercentIcon,
@@ -65,12 +103,39 @@ export function RelativeData({
65
103
  const id = useWidgetId()
66
104
  const _labels = { ...DEFAULT_RELATIVE_DATA_LABELS, ...labels }
67
105
 
68
- const { enabled, toggle } = useSingleTransform(
106
+ const persistedScale = useWidget(
69
107
  id,
70
- DATA_DESCRIPTOR,
71
- transform ?? toRelativeData,
72
- { initialEnabled },
108
+ (s) =>
109
+ s.transformStates[DATA_DESCRIPTOR.id]?.scale as RelativeScale | undefined,
73
110
  )
111
+ const scale = persistedScale ?? initialScale
112
+
113
+ const pairs = useMemo<readonly TransformPair[]>(
114
+ () => [
115
+ { descriptor: DATA_DESCRIPTOR, fn: transform ?? toRelativeData },
116
+ {
117
+ descriptor: AXIS_DESCRIPTOR,
118
+ fn: scale === 'total' ? toRelativeTotalAxis : identity,
119
+ },
120
+ ],
121
+ [transform, scale],
122
+ )
123
+
124
+ const { enabled, setEnabled, toggle } = useTransform(id, pairs, {
125
+ initialEnabled,
126
+ })
127
+
128
+ useEffect(() => {
129
+ if (persistedScale === undefined)
130
+ writeScale(id, initialScale, initialEnabled)
131
+ }, [id, persistedScale, initialScale, initialEnabled])
132
+
133
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
134
+ const selectScale = (next: RelativeScale) => {
135
+ writeScale(id, next, initialEnabled)
136
+ setEnabled(true)
137
+ setAnchorEl(null)
138
+ }
74
139
 
75
140
  const percentFormatter = useMemo(
76
141
  () => createPercentFormatter(locale),
@@ -92,16 +157,68 @@ export function RelativeData({
92
157
  const tooltip = enabled ? _labels.on : _labels.off
93
158
 
94
159
  return (
95
- <Tooltip title={tooltip}>
96
- <IconButton
97
- size='small'
98
- aria-label={tooltip}
99
- aria-pressed={enabled}
100
- onClick={toggle}
101
- sx={{ ...styles.toggle, ...(enabled && styles.toggleActive) }}
160
+ <Box sx={styles.group}>
161
+ <Tooltip title={tooltip}>
162
+ <IconButton
163
+ size='small'
164
+ aria-label={tooltip}
165
+ aria-pressed={enabled}
166
+ onClick={toggle}
167
+ sx={{ ...styles.toggle, ...(enabled && styles.toggleActive) }}
168
+ >
169
+ <Icon fontSize='small' {...iconProps} />
170
+ </IconButton>
171
+ </Tooltip>
172
+ <Tooltip title={_labels.scaleMenu}>
173
+ <IconButton
174
+ size='small'
175
+ aria-label={_labels.scaleMenu}
176
+ aria-haspopup='true'
177
+ aria-expanded={anchorEl ? 'true' : undefined}
178
+ onClick={(e) => setAnchorEl(e.currentTarget)}
179
+ sx={styles.arrow}
180
+ >
181
+ <ArrowDropDownIcon />
182
+ </IconButton>
183
+ </Tooltip>
184
+ <Menu
185
+ anchorEl={anchorEl}
186
+ open={Boolean(anchorEl)}
187
+ onClose={() => setAnchorEl(null)}
188
+ variant='menu'
189
+ elevation={8}
190
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
191
+ transformOrigin={{ vertical: 'top', horizontal: 'right' }}
102
192
  >
103
- <Icon fontSize='small' {...iconProps} />
104
- </IconButton>
105
- </Tooltip>
193
+ {SCALES.map((option) => (
194
+ <MenuItem
195
+ key={option}
196
+ selected={option === scale}
197
+ onClick={() => selectScale(option)}
198
+ >
199
+ <ListItemText>
200
+ {option === 'max' ? _labels.scaleMax : _labels.scaleTotal}
201
+ </ListItemText>
202
+ </MenuItem>
203
+ ))}
204
+ </Menu>
205
+ </Box>
106
206
  )
107
207
  }
208
+
209
+ const SCALES: readonly RelativeScale[] = ['max', 'total']
210
+
211
+ function writeScale(id: string, scale: RelativeScale, initialEnabled: boolean) {
212
+ getWidgetStore(id).setState((s) => ({
213
+ transformStates: {
214
+ ...s.transformStates,
215
+ [DATA_DESCRIPTOR.id]: {
216
+ // `useSingleTransform` seeds `enabled` itself; this fallback only
217
+ // keeps the slot well-typed if the scale lands first.
218
+ enabled: initialEnabled,
219
+ ...s.transformStates[DATA_DESCRIPTOR.id],
220
+ scale,
221
+ },
222
+ },
223
+ }))
224
+ }
@@ -0,0 +1,48 @@
1
+ import type { WidgetState } from '../../stores/types'
2
+
3
+ /**
4
+ * How far a bar reaches while `RelativeData` is on. The value is always
5
+ * the share of the series total; the scale only moves the end of the
6
+ * value axis: `'max'` ends it at the largest share so the top item fills
7
+ * the bar, `'total'` ends it at 100%.
8
+ */
9
+ export type RelativeScale = 'max' | 'total'
10
+
11
+ export const RELATIVE_DATA_ID = 'relative-data'
12
+
13
+ /**
14
+ * The scale the value axis should use right now, or `null` when relative
15
+ * values are off. For widgets that size bars themselves (Category); the
16
+ * ECharts widgets get it through the config transform below.
17
+ */
18
+ export function selectRelativeScale(s: WidgetState): RelativeScale | null {
19
+ const slot = s.transformStates[RELATIVE_DATA_ID]
20
+ if (slot?.enabled !== true) return null
21
+ return (slot.scale as RelativeScale | undefined) ?? 'max'
22
+ }
23
+
24
+ /**
25
+ * Config transform for `'total'`: pins the value axis to ±100. The widget
26
+ * mergers read numeric `yAxis.min` / `yAxis.max` back through
27
+ * {@link fixedAxisBounds} and apply each only on the side the data reaches.
28
+ */
29
+ export const toRelativeTotalAxis = (option: unknown): unknown => {
30
+ if (option == null || typeof option !== 'object') return option
31
+ const cfg = option as Record<string, unknown>
32
+ const yAxis = cfg.yAxis
33
+ if (yAxis == null || typeof yAxis !== 'object' || Array.isArray(yAxis))
34
+ return option
35
+ return { ...cfg, yAxis: { ...yAxis, min: -100, max: 100 } }
36
+ }
37
+
38
+ /** Numeric bounds a config transform set on the structural `yAxis`. */
39
+ export function fixedAxisBounds(yAxis: object): {
40
+ min?: number
41
+ max?: number
42
+ } {
43
+ const { min, max } = yAxis as { min?: unknown; max?: unknown }
44
+ return {
45
+ min: typeof min === 'number' ? min : undefined,
46
+ max: typeof max === 'number' ? max : undefined,
47
+ }
48
+ }
@@ -1,6 +1,10 @@
1
1
  import type { SxProps, Theme } from '@mui/material'
2
2
 
3
3
  export const styles = {
4
+ group: {
5
+ display: 'inline-flex',
6
+ alignItems: 'center',
7
+ },
4
8
  toggle: {
5
9
  p: 0.5,
6
10
  '& .MuiSvgIcon-root': { fontSize: 20 },
@@ -8,4 +12,10 @@ export const styles = {
8
12
  toggleActive: {
9
13
  background: (theme: Theme) => theme.palette.primary.relatedLight,
10
14
  },
15
+ arrow: {
16
+ p: 0,
17
+ width: 16,
18
+ borderRadius: 0.5,
19
+ '& .MuiSvgIcon-root': { fontSize: 18 },
20
+ },
11
21
  } satisfies Record<string, SxProps<Theme>>
@@ -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
+ })