@carto/ps-react-ui 5.5.0 → 5.6.1
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.
- package/dist/actions-5-dVhroJ.js +1289 -0
- package/dist/actions-5-dVhroJ.js.map +1 -0
- package/dist/{category-C6myUFUJ.js → category-DBUD2ZBo.js} +267 -250
- package/dist/category-DBUD2ZBo.js.map +1 -0
- package/dist/scale-0elCv3tk.js +29 -0
- package/dist/scale-0elCv3tk.js.map +1 -0
- package/dist/types/widgets-v2/actions/index.d.ts +1 -1
- package/dist/types/widgets-v2/actions/relative-data/index.d.ts +1 -0
- package/dist/types/widgets-v2/actions/relative-data/labels.d.ts +3 -0
- package/dist/types/widgets-v2/actions/relative-data/relative-data.d.ts +16 -2
- package/dist/types/widgets-v2/actions/relative-data/scale.d.ts +26 -0
- package/dist/types/widgets-v2/actions/relative-data/style.d.ts +12 -0
- package/dist/types/widgets-v2/category/category.d.ts +2 -1
- package/dist/types/widgets-v2/index.d.ts +1 -1
- package/dist/types/widgets-v2/wrapper/collapsed-context.d.ts +7 -0
- package/dist/widgets-v2/actions.js +1 -1
- package/dist/widgets-v2/bar.js +84 -83
- package/dist/widgets-v2/bar.js.map +1 -1
- package/dist/widgets-v2/category.js +1 -1
- package/dist/widgets-v2/histogram.js +108 -107
- package/dist/widgets-v2/histogram.js.map +1 -1
- package/dist/widgets-v2/scatterplot.js +120 -119
- package/dist/widgets-v2/scatterplot.js.map +1 -1
- package/dist/widgets-v2/timeseries.js +90 -89
- package/dist/widgets-v2/timeseries.js.map +1 -1
- package/dist/widgets-v2.js +223 -217
- package/dist/widgets-v2.js.map +1 -1
- package/package.json +3 -3
- package/src/widgets-v2/actions/index.ts +1 -0
- package/src/widgets-v2/actions/relative-data/index.ts +1 -0
- package/src/widgets-v2/actions/relative-data/labels.ts +6 -0
- package/src/widgets-v2/actions/relative-data/relative-data.test.tsx +78 -1
- package/src/widgets-v2/actions/relative-data/relative-data.tsx +137 -20
- package/src/widgets-v2/actions/relative-data/scale.ts +48 -0
- package/src/widgets-v2/actions/relative-data/style.ts +10 -0
- package/src/widgets-v2/bar/options.test.ts +16 -0
- package/src/widgets-v2/bar/options.ts +6 -2
- package/src/widgets-v2/category/category.relative-data.test.tsx +51 -63
- package/src/widgets-v2/category/category.tsx +25 -11
- package/src/widgets-v2/histogram/options.ts +6 -2
- package/src/widgets-v2/index.ts +1 -0
- package/src/widgets-v2/scatterplot/options.test.ts +12 -0
- package/src/widgets-v2/scatterplot/options.ts +18 -4
- package/src/widgets-v2/timeseries/options.ts +6 -2
- package/src/widgets-v2/toolbox/toolbox.test.tsx +28 -0
- package/src/widgets-v2/toolbox/toolbox.tsx +9 -0
- package/src/widgets-v2/wrapper/collapsed-context.ts +12 -0
- package/src/widgets-v2/wrapper/widget-wrapper.tsx +6 -1
- package/dist/actions-CwjqEm8Y.js +0 -1177
- package/dist/actions-CwjqEm8Y.js.map +0 -1
- package/dist/category-C6myUFUJ.js.map +0 -1
|
@@ -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 {
|
|
3
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
106
|
+
const persistedScale = useWidget(
|
|
69
107
|
id,
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
<
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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>>
|
|
@@ -237,6 +237,22 @@ describe('createBarOptionFactory', () => {
|
|
|
237
237
|
expect(out.yAxis.axisLabel.formatter(0)).toBe('')
|
|
238
238
|
})
|
|
239
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
|
+
|
|
240
256
|
it('rounds the ECharts-supplied (stacked) extent so stacked bars cannot overflow', () => {
|
|
241
257
|
// Regression: previously the merge phase precomputed the y-axis max from
|
|
242
258
|
// the largest *single* series value, so stacking (StackToggle →
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
BarOptionsInput,
|
|
21
21
|
BarWidgetData,
|
|
22
22
|
} from './types'
|
|
23
|
+
import { fixedAxisBounds } from '../actions/relative-data/scale'
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Builds the **structural** ECharts option for a bar widget — axes, grid,
|
|
@@ -202,6 +203,7 @@ export function createBarOptionFactory(
|
|
|
202
203
|
typeof option.yAxis === 'object' && !Array.isArray(option.yAxis)
|
|
203
204
|
? option.yAxis
|
|
204
205
|
: {}
|
|
206
|
+
const fixedY = fixedAxisBounds(baseYAxis)
|
|
205
207
|
|
|
206
208
|
const seriesTemplates = Array.isArray(option.series) ? option.series : []
|
|
207
209
|
const broadcastTemplate = seriesTemplates[0] ?? {}
|
|
@@ -300,12 +302,14 @@ export function createBarOptionFactory(
|
|
|
300
302
|
...(dataZoomLayout ? { dataZoom: dataZoomLayout } : {}),
|
|
301
303
|
yAxis: {
|
|
302
304
|
...baseYAxis,
|
|
305
|
+
// A config transform (RelativeData's `'total'` scale) may pin the
|
|
306
|
+
// ends; each applies only on the side the data reaches.
|
|
303
307
|
min: (extent: { min: number }) => {
|
|
304
|
-
niceMin = extent.min < 0 ? niceNum(extent.min) : 0
|
|
308
|
+
niceMin = extent.min < 0 ? (fixedY.min ?? niceNum(extent.min)) : 0
|
|
305
309
|
return niceMin
|
|
306
310
|
},
|
|
307
311
|
max: (extent: { min: number; max: number }) => {
|
|
308
|
-
niceMax = extent.max <= 0 ? 1 : niceNum(extent.max)
|
|
312
|
+
niceMax = extent.max <= 0 ? 1 : (fixedY.max ?? niceNum(extent.max))
|
|
309
313
|
return niceMax
|
|
310
314
|
},
|
|
311
315
|
axisLabel: {
|
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
<
|
|
35
|
+
<Category />
|
|
51
36
|
</Provider>,
|
|
52
37
|
)
|
|
53
|
-
|
|
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('
|
|
41
|
+
it("scale 'max' (default): values are shares of the total, the top bar fills", () => {
|
|
62
42
|
render(
|
|
63
|
-
<Provider id='cat-rd-
|
|
43
|
+
<Provider id='cat-rd-max' data={DATA}>
|
|
64
44
|
<RelativeData />
|
|
65
|
-
<
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
expect(
|
|
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
|
-
<
|
|
86
|
+
<RelativeData scale='total' />
|
|
87
|
+
<Category />
|
|
97
88
|
</Provider>,
|
|
98
89
|
)
|
|
99
|
-
const button = screen.getByRole('button', { name: /relative
|
|
90
|
+
const button = screen.getByRole('button', { name: /show relative values/i })
|
|
100
91
|
fireEvent.click(button) // on
|
|
101
92
|
fireEvent.click(button) // off
|
|
102
|
-
|
|
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
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
156
|
+
naturalMax > 0
|
|
157
|
+
? ((relativeScale ? undefined : maxOverride) ?? naturalMax)
|
|
158
|
+
: undefined
|
|
147
159
|
const effectiveMinOverride =
|
|
148
|
-
naturalMin < 0
|
|
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<
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
HistogramBin,
|
|
23
23
|
HistogramWidgetData,
|
|
24
24
|
} from './types'
|
|
25
|
+
import { fixedAxisBounds } from '../actions/relative-data/scale'
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Builds the **structural** ECharts option for a histogram widget — axes,
|
|
@@ -216,6 +217,7 @@ export function createHistogramOptionFactory(
|
|
|
216
217
|
typeof option.yAxis === 'object' && !Array.isArray(option.yAxis)
|
|
217
218
|
? option.yAxis
|
|
218
219
|
: {}
|
|
220
|
+
const fixedY = fixedAxisBounds(baseYAxis)
|
|
219
221
|
const baseGrid =
|
|
220
222
|
typeof option.grid === 'object' && !Array.isArray(option.grid)
|
|
221
223
|
? option.grid
|
|
@@ -354,12 +356,14 @@ export function createHistogramOptionFactory(
|
|
|
354
356
|
...(dataZoomLayout ? { dataZoom: dataZoomLayout } : {}),
|
|
355
357
|
yAxis: {
|
|
356
358
|
...baseYAxis,
|
|
359
|
+
// A config transform (RelativeData's `'total'` scale) may pin the
|
|
360
|
+
// ends; each applies only on the side the data reaches.
|
|
357
361
|
min: (extent: { min: number }) => {
|
|
358
|
-
niceMin = extent.min < 0 ? niceNum(extent.min) : 0
|
|
362
|
+
niceMin = extent.min < 0 ? (fixedY.min ?? niceNum(extent.min)) : 0
|
|
359
363
|
return niceMin
|
|
360
364
|
},
|
|
361
365
|
max: (extent: { min: number; max: number }) => {
|
|
362
|
-
niceMax = extent.max <= 0 ? 1 : niceNum(extent.max)
|
|
366
|
+
niceMax = extent.max <= 0 ? 1 : (fixedY.max ?? niceNum(extent.max))
|
|
363
367
|
return niceMax
|
|
364
368
|
},
|
|
365
369
|
axisLabel: {
|