@carto/ps-react-ui 6.3.0 → 6.5.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 (113) hide show
  1. package/dist/actions-D_PVFZaM.js +1272 -0
  2. package/dist/actions-D_PVFZaM.js.map +1 -0
  3. package/dist/{category-DULnoxvk.js → category-UIGvFN3D.js} +263 -246
  4. package/dist/category-UIGvFN3D.js.map +1 -0
  5. package/dist/chat.js +472 -470
  6. package/dist/chat.js.map +1 -1
  7. package/dist/{components-CTUpdQuj.js → components-DDJU4m0t.js} +8 -8
  8. package/dist/components-DDJU4m0t.js.map +1 -0
  9. package/dist/components.js +2 -2
  10. package/dist/{download-B4sykOFm.js → download-DIj1Iz0t.js} +2 -2
  11. package/dist/{download-B4sykOFm.js.map → download-DIj1Iz0t.js.map} +1 -1
  12. package/dist/{formula-DJS-JLpk.js → formula-CSHhAoyW.js} +4 -4
  13. package/dist/{formula-DJS-JLpk.js.map → formula-CSHhAoyW.js.map} +1 -1
  14. package/dist/{item-color-B7xkIpDZ.js → item-color-7o7pFBR2.js} +26 -18
  15. package/dist/item-color-7o7pFBR2.js.map +1 -0
  16. package/dist/markdown-B8lLR-NY.js +106 -0
  17. package/dist/markdown-B8lLR-NY.js.map +1 -0
  18. package/dist/scale-Bhx8HKEB.js +29 -0
  19. package/dist/scale-Bhx8HKEB.js.map +1 -0
  20. package/dist/{spread-CQwFIwuz.js → spread-BRfPPIep.js} +3 -3
  21. package/dist/{spread-CQwFIwuz.js.map → spread-BRfPPIep.js.map} +1 -1
  22. package/dist/style-DTLuIo84.js +87 -0
  23. package/dist/style-DTLuIo84.js.map +1 -0
  24. package/dist/{styles-BCEBm7xh.js → styles-yKcpfyNV.js} +5 -5
  25. package/dist/styles-yKcpfyNV.js.map +1 -0
  26. package/dist/{table-DEdW_fDj.js → table-Col_FRvb.js} +2 -2
  27. package/dist/{table-DEdW_fDj.js.map → table-Col_FRvb.js.map} +1 -1
  28. package/dist/theme/tailwind.css +373 -0
  29. package/dist/theme.js +123 -0
  30. package/dist/theme.js.map +1 -0
  31. package/dist/types/components/lasso-tool/styles.d.ts +4 -4
  32. package/dist/types/theme/create-carto-theme.d.ts +7 -0
  33. package/dist/types/theme/deep-merge.d.ts +6 -0
  34. package/dist/types/theme/generate-tailwind-css.d.ts +8 -0
  35. package/dist/types/theme/index.d.ts +2 -0
  36. package/dist/types/widgets/actions/index.d.ts +1 -1
  37. package/dist/types/widgets/actions/relative-data/index.d.ts +1 -0
  38. package/dist/types/widgets/actions/relative-data/labels.d.ts +3 -0
  39. package/dist/types/widgets/actions/relative-data/relative-data.d.ts +16 -2
  40. package/dist/types/widgets/actions/relative-data/scale.d.ts +26 -0
  41. package/dist/types/widgets/actions/relative-data/style.d.ts +12 -0
  42. package/dist/types/widgets/category/category.d.ts +2 -1
  43. package/dist/types/widgets/index.d.ts +1 -1
  44. package/dist/types/widgets/markdown/style.d.ts +74 -0
  45. package/dist/types/widgets/utils/spacing-px.d.ts +13 -0
  46. package/dist/{utils-C-eWwOhE.js → utils-Dw4y8tiq.js} +2 -2
  47. package/dist/{utils-C-eWwOhE.js.map → utils-Dw4y8tiq.js.map} +1 -1
  48. package/dist/widgets/actions.js +3 -3
  49. package/dist/widgets/bar.js +85 -84
  50. package/dist/widgets/bar.js.map +1 -1
  51. package/dist/widgets/category.js +1 -1
  52. package/dist/widgets/formula.js +1 -1
  53. package/dist/widgets/histogram.js +109 -108
  54. package/dist/widgets/histogram.js.map +1 -1
  55. package/dist/widgets/markdown.js +1 -1
  56. package/dist/widgets/pie.js +78 -78
  57. package/dist/widgets/pie.js.map +1 -1
  58. package/dist/widgets/scatterplot.js +121 -120
  59. package/dist/widgets/scatterplot.js.map +1 -1
  60. package/dist/widgets/spread.js +1 -1
  61. package/dist/widgets/table.js +1 -1
  62. package/dist/widgets/timeseries.js +91 -90
  63. package/dist/widgets/timeseries.js.map +1 -1
  64. package/dist/widgets/utils.js +1 -1
  65. package/dist/widgets.js +283 -279
  66. package/dist/widgets.js.map +1 -1
  67. package/dist/{zoom-toggle-MmLjMTEw.js → zoom-toggle-DqrV_0Ji.js} +2 -2
  68. package/dist/{zoom-toggle-MmLjMTEw.js.map → zoom-toggle-DqrV_0Ji.js.map} +1 -1
  69. package/package.json +8 -1
  70. package/src/chat/bubbles/styles.ts +2 -0
  71. package/src/components/lasso-tool/lasso-tool-inline.test.tsx +4 -4
  72. package/src/components/lasso-tool/lasso-tool-inline.tsx +6 -6
  73. package/src/components/lasso-tool/styles.ts +4 -4
  74. package/src/theme/create-carto-theme.test.ts +53 -0
  75. package/src/theme/create-carto-theme.ts +21 -0
  76. package/src/theme/deep-merge.test.ts +25 -0
  77. package/src/theme/deep-merge.ts +22 -0
  78. package/src/theme/generate-tailwind-css.test.ts +87 -0
  79. package/src/theme/generate-tailwind-css.ts +178 -0
  80. package/src/theme/index.ts +2 -0
  81. package/src/theme/tailwind-compile.test.ts +71 -0
  82. package/src/theme/tailwind.css +373 -0
  83. package/src/widgets/actions/index.ts +1 -0
  84. package/src/widgets/actions/relative-data/index.ts +1 -0
  85. package/src/widgets/actions/relative-data/labels.ts +6 -0
  86. package/src/widgets/actions/relative-data/relative-data.test.tsx +78 -1
  87. package/src/widgets/actions/relative-data/relative-data.tsx +137 -20
  88. package/src/widgets/actions/relative-data/scale.ts +48 -0
  89. package/src/widgets/actions/relative-data/style.ts +10 -0
  90. package/src/widgets/bar/options.test.ts +16 -0
  91. package/src/widgets/bar/options.ts +13 -8
  92. package/src/widgets/category/category.relative-data.test.tsx +51 -63
  93. package/src/widgets/category/category.tsx +25 -11
  94. package/src/widgets/histogram/options.ts +13 -8
  95. package/src/widgets/index.ts +1 -0
  96. package/src/widgets/markdown/style.ts +40 -0
  97. package/src/widgets/note/widget-note.tsx +4 -1
  98. package/src/widgets/pie/options.ts +5 -4
  99. package/src/widgets/scatterplot/options.test.ts +12 -0
  100. package/src/widgets/scatterplot/options.ts +25 -10
  101. package/src/widgets/timeseries/options.ts +13 -8
  102. package/src/widgets/utils/css-variables-theme.test.ts +32 -0
  103. package/src/widgets/utils/option-builders.ts +4 -6
  104. package/src/widgets/utils/spacing-px.test.ts +42 -0
  105. package/src/widgets/utils/spacing-px.ts +24 -0
  106. package/dist/actions-CkxKe8-J.js +0 -1160
  107. package/dist/actions-CkxKe8-J.js.map +0 -1
  108. package/dist/category-DULnoxvk.js.map +0 -1
  109. package/dist/components-CTUpdQuj.js.map +0 -1
  110. package/dist/item-color-B7xkIpDZ.js.map +0 -1
  111. package/dist/markdown-DmvLi3vx.js +0 -159
  112. package/dist/markdown-DmvLi3vx.js.map +0 -1
  113. package/dist/styles-BCEBm7xh.js.map +0 -1
@@ -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>>
@@ -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 →
@@ -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 { spacingPx } from '../utils/spacing-px'
15
16
  import { createItemColorResolver } from '../utils/item-color'
16
17
  import { positionDataZoomForLegend } from '../utils/data-zoom-layout'
17
18
  import type {
@@ -20,6 +21,7 @@ import type {
20
21
  BarOptionsInput,
21
22
  BarWidgetData,
22
23
  } from './types'
24
+ import { fixedAxisBounds } from '../actions/relative-data/scale'
23
25
 
24
26
  /**
25
27
  * Builds the **structural** ECharts option for a bar widget — axes, grid,
@@ -49,9 +51,9 @@ export function barOptions({
49
51
 
50
52
  return {
51
53
  grid: {
52
- left: parseInt(theme.spacing(1)),
53
- top: parseInt(theme.spacing(3)),
54
- right: parseInt(theme.spacing(1)),
54
+ left: spacingPx(theme, 1),
55
+ top: spacingPx(theme, 3),
56
+ right: spacingPx(theme, 1),
55
57
  // Default: no legend. Merger bumps this when there are >1 series.
56
58
  ...buildGridConfig(false, theme),
57
59
  containLabel: true,
@@ -60,7 +62,7 @@ export function barOptions({
60
62
  trigger: 'axis',
61
63
  backgroundColor: theme.palette.grey[900],
62
64
  borderWidth: 0,
63
- padding: [parseInt(theme.spacing(1)), parseInt(theme.spacing(1))],
65
+ padding: [spacingPx(theme, 1), spacingPx(theme, 1)],
64
66
  textStyle: {
65
67
  color: theme.palette.common.white,
66
68
  fontSize: 11,
@@ -89,7 +91,7 @@ export function barOptions({
89
91
  axisTick: { show: false },
90
92
  axisLabel: {
91
93
  ...buildAxisLabelStyle(theme),
92
- padding: [parseInt(theme.spacing(0.5)), 0, 0, 0],
94
+ padding: [spacingPx(theme, 0.5), 0, 0, 0],
93
95
  margin: 0,
94
96
  // `interval: 0` makes every category label a candidate so `hideOverlap`
95
97
  // can pack the maximum number that physically fit; the category-axis
@@ -119,7 +121,7 @@ export function barOptions({
119
121
  },
120
122
  axisLabel: {
121
123
  ...buildAxisLabelStyle(theme),
122
- margin: parseInt(theme.spacing(1)),
124
+ margin: spacingPx(theme, 1),
123
125
  show: true,
124
126
  showMaxLabel: true,
125
127
  showMinLabel: true,
@@ -202,6 +204,7 @@ export function createBarOptionFactory(
202
204
  typeof option.yAxis === 'object' && !Array.isArray(option.yAxis)
203
205
  ? option.yAxis
204
206
  : {}
207
+ const fixedY = fixedAxisBounds(baseYAxis)
205
208
 
206
209
  const seriesTemplates = Array.isArray(option.series) ? option.series : []
207
210
  const broadcastTemplate = seriesTemplates[0] ?? {}
@@ -300,12 +303,14 @@ export function createBarOptionFactory(
300
303
  ...(dataZoomLayout ? { dataZoom: dataZoomLayout } : {}),
301
304
  yAxis: {
302
305
  ...baseYAxis,
306
+ // A config transform (RelativeData's `'total'` scale) may pin the
307
+ // ends; each applies only on the side the data reaches.
303
308
  min: (extent: { min: number }) => {
304
- niceMin = extent.min < 0 ? niceNum(extent.min) : 0
309
+ niceMin = extent.min < 0 ? (fixedY.min ?? niceNum(extent.min)) : 0
305
310
  return niceMin
306
311
  },
307
312
  max: (extent: { min: number; max: number }) => {
308
- niceMax = extent.max <= 0 ? 1 : niceNum(extent.max)
313
+ niceMax = extent.max <= 0 ? 1 : (fixedY.max ?? niceNum(extent.max))
309
314
  return niceMax
310
315
  },
311
316
  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
- // 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<